fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""Regression tests for the YAML-driven ModelRegistry.
|
||
|
||
These tests encode the contract that persisted agent / workflow /
|
||
conversation references depend on: every model id and core capability
|
||
that existed in the old ``model_configs.py`` lists must continue to be
|
||
produced by the new YAML-backed registry.
|
||
|
||
If a future YAML edit accidentally renames an id or changes a
|
||
capability, these tests fail at CI before merge — protecting agents and
|
||
workflows from silent fallback to the system default.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from application.core.model_registry import ModelRegistry
|
||
from application.core.model_yaml import (
|
||
BUILTIN_MODELS_DIR,
|
||
load_model_yamls,
|
||
)
|
||
|
||
|
||
# ── Per-provider expected IDs ─────────────────────────────────────────────
|
||
# Snapshot of the current built-in catalog. If you intentionally change
|
||
# what models a provider's YAML lists, update this constant in the same
|
||
# commit. The test exists to catch *unintentional* renames (e.g. a typo
|
||
# in an upstream model id) that would silently break every agent that
|
||
# references the old id.
|
||
EXPECTED_IDS = {
|
||
"openai": {"gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano"},
|
||
"anthropic": {
|
||
"claude-opus-4-7",
|
||
"claude-sonnet-4-6",
|
||
"claude-haiku-4-5",
|
||
},
|
||
"google": {
|
||
"gemini-3.1-pro-preview",
|
||
"gemini-3.5-flash",
|
||
"gemini-3.1-flash-lite",
|
||
},
|
||
"groq": {
|
||
"openai/gpt-oss-120b",
|
||
"llama-3.3-70b-versatile",
|
||
"llama-3.1-8b-instant",
|
||
},
|
||
"openrouter": {
|
||
"qwen/qwen3-coder:free",
|
||
"deepseek/deepseek-v3.2",
|
||
"anthropic/claude-sonnet-4.6",
|
||
},
|
||
"novita": {
|
||
"deepseek/deepseek-v4-pro",
|
||
"moonshotai/kimi-k2.6",
|
||
"zai-org/glm-5",
|
||
},
|
||
"openai_compatible": {
|
||
"deepseek-v4-flash",
|
||
"deepseek-v4-pro",
|
||
},
|
||
"docsgpt": {"docsgpt-local"},
|
||
"huggingface": {"huggingface-local"},
|
||
}
|
||
|
||
|
||
def _make_settings(**overrides):
|
||
s = MagicMock()
|
||
# All credential / mode flags off by default so each test opts in.
|
||
s.OPENAI_BASE_URL = None
|
||
s.OPENAI_API_KEY = None
|
||
s.OPENAI_API_BASE = None
|
||
s.ANTHROPIC_API_KEY = None
|
||
s.GOOGLE_API_KEY = None
|
||
s.GROQ_API_KEY = None
|
||
s.OPEN_ROUTER_API_KEY = None
|
||
s.NOVITA_API_KEY = None
|
||
s.HUGGINGFACE_API_KEY = None
|
||
s.LLM_PROVIDER = ""
|
||
s.LLM_NAME = None
|
||
s.API_KEY = None
|
||
s.MODELS_CONFIG_DIR = None
|
||
for k, v in overrides.items():
|
||
setattr(s, k, v)
|
||
return s
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_registry(monkeypatch):
|
||
ModelRegistry.reset()
|
||
# openai_compatible catalogs read their key directly from os.environ,
|
||
# so clear any host-set keys to keep these tests deterministic.
|
||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||
yield
|
||
ModelRegistry.reset()
|
||
|
||
|
||
# ── YAML schema / loader ─────────────────────────────────────────────────
|
||
|
||
|
||
def _by_provider(catalogs):
|
||
"""Group a list of catalogs by provider name. Mirrors the registry's
|
||
own grouping; useful for asserting per-provider model sets in tests."""
|
||
out = {}
|
||
for c in catalogs:
|
||
out.setdefault(c.provider, []).append(c)
|
||
return out
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestYAMLLoader:
|
||
def test_loader_produces_expected_provider_set(self):
|
||
catalogs = load_model_yamls([BUILTIN_MODELS_DIR])
|
||
providers = {c.provider for c in catalogs}
|
||
assert providers == set(EXPECTED_IDS.keys())
|
||
|
||
def test_each_provider_has_expected_ids(self):
|
||
grouped = _by_provider(load_model_yamls([BUILTIN_MODELS_DIR]))
|
||
for provider, expected in EXPECTED_IDS.items():
|
||
actual = {m.id for c in grouped[provider] for m in c.models}
|
||
assert actual == expected, f"{provider}: expected {expected}, got {actual}"
|
||
|
||
def test_attachment_alias_image_expands_to_five_mime_types(self):
|
||
grouped = _by_provider(load_model_yamls([BUILTIN_MODELS_DIR]))
|
||
# OpenAI uses `attachments: [image]` in its defaults block.
|
||
for c in grouped["openai"]:
|
||
for m in c.models:
|
||
assert "image/png" in m.capabilities.supported_attachment_types
|
||
assert "image/jpeg" in m.capabilities.supported_attachment_types
|
||
assert "image/webp" in m.capabilities.supported_attachment_types
|
||
assert len(m.capabilities.supported_attachment_types) == 5
|
||
|
||
def test_attachment_alias_pdf_plus_image_for_google(self):
|
||
grouped = _by_provider(load_model_yamls([BUILTIN_MODELS_DIR]))
|
||
for c in grouped["google"]:
|
||
for m in c.models:
|
||
assert "application/pdf" in m.capabilities.supported_attachment_types
|
||
assert "image/png" in m.capabilities.supported_attachment_types
|
||
assert len(m.capabilities.supported_attachment_types) == 6
|
||
|
||
def test_per_model_context_window_overrides_provider_default(self):
|
||
grouped = _by_provider(load_model_yamls([BUILTIN_MODELS_DIR]))
|
||
openai = {m.id: m for c in grouped["openai"] for m in c.models}
|
||
# Provider default is 400_000; gpt-5.5 overrides to 1_050_000.
|
||
assert openai["gpt-5.4-mini"].capabilities.context_window == 400_000
|
||
assert openai["gpt-5.5"].capabilities.context_window == 1_050_000
|
||
|
||
|
||
# ── Registry × settings: every documented .env permutation ───────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestRegistryPermutations:
|
||
def test_openai_only(self):
|
||
s = _make_settings(OPENAI_API_KEY="sk-test", LLM_PROVIDER="openai")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["openai"] | EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_openai_base_url_replaces_catalog_with_dynamic(self):
|
||
s = _make_settings(
|
||
OPENAI_BASE_URL="http://localhost:11434/v1",
|
||
OPENAI_API_KEY="sk-test",
|
||
LLM_PROVIDER="openai",
|
||
LLM_NAME="llama3,gemma",
|
||
)
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
# Custom local endpoint suppresses both the openai catalog AND
|
||
# the docsgpt model (matching legacy behavior).
|
||
assert ids == {"llama3", "gemma"}
|
||
|
||
def test_anthropic_only(self):
|
||
s = _make_settings(ANTHROPIC_API_KEY="sk-ant")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["anthropic"] | EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_anthropic_via_llm_provider_with_llm_name(self):
|
||
# Mirrors the historical _add_anthropic_models filter: when only
|
||
# API_KEY (not ANTHROPIC_API_KEY) is set and LLM_NAME matches a
|
||
# known model, only that model is loaded.
|
||
s = _make_settings(
|
||
LLM_PROVIDER="anthropic", API_KEY="key", LLM_NAME="claude-haiku-4-5"
|
||
)
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
anthropic_ids = {
|
||
m.id for m in reg.get_all_models() if m.provider.value == "anthropic"
|
||
}
|
||
assert anthropic_ids == {"claude-haiku-4-5"}
|
||
|
||
def test_google_only(self):
|
||
s = _make_settings(GOOGLE_API_KEY="g-test")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["google"] | EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_groq_only(self):
|
||
s = _make_settings(GROQ_API_KEY="g-test")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["groq"] | EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_openrouter_only(self):
|
||
s = _make_settings(OPEN_ROUTER_API_KEY="or-test")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["openrouter"] | EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_novita_only(self):
|
||
s = _make_settings(NOVITA_API_KEY="n-test")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["novita"] | EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_huggingface_only(self):
|
||
s = _make_settings(HUGGINGFACE_API_KEY="hf-test")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["huggingface"] | EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_no_credentials_only_docsgpt(self):
|
||
s = _make_settings()
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
assert ids == EXPECTED_IDS["docsgpt"]
|
||
|
||
def test_everything_set(self, monkeypatch):
|
||
monkeypatch.setenv("DEEPSEEK_API_KEY", "x")
|
||
s = _make_settings(
|
||
OPENAI_API_KEY="x",
|
||
ANTHROPIC_API_KEY="x",
|
||
GOOGLE_API_KEY="x",
|
||
GROQ_API_KEY="x",
|
||
OPEN_ROUTER_API_KEY="x",
|
||
NOVITA_API_KEY="x",
|
||
HUGGINGFACE_API_KEY="x",
|
||
OPENAI_API_BASE="x",
|
||
)
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
ids = {m.id for m in reg.get_all_models()}
|
||
all_expected = set()
|
||
for v in EXPECTED_IDS.values():
|
||
all_expected |= v
|
||
assert ids == all_expected
|
||
|
||
|
||
# ── Default model resolution ─────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestDefaultModelResolution:
|
||
def test_llm_name_picks_default(self):
|
||
s = _make_settings(
|
||
ANTHROPIC_API_KEY="sk-ant", LLM_NAME="claude-opus-4-7"
|
||
)
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
assert reg.default_model_id == "claude-opus-4-7"
|
||
|
||
def test_falls_back_to_first_model_when_no_match(self):
|
||
s = _make_settings()
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
assert reg.default_model_id is not None
|
||
assert reg.default_model_id in reg.models
|
||
|
||
|
||
# ── Forward-compat: user_id parameter is accepted everywhere ─────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestUserIdForwardCompat:
|
||
def test_lookup_methods_accept_user_id(self):
|
||
s = _make_settings(OPENAI_API_KEY="sk-test")
|
||
with patch("application.core.settings.settings", s):
|
||
reg = ModelRegistry()
|
||
# All lookup methods must accept user_id (currently ignored,
|
||
# reserved for end-user BYOM).
|
||
assert reg.get_model("gpt-5.5", user_id="alice") is not None
|
||
assert len(reg.get_all_models(user_id="alice")) > 0
|
||
assert len(reg.get_enabled_models(user_id="alice")) > 0
|
||
assert reg.model_exists("gpt-5.5", user_id="alice") is True
|