chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
"""Tests for ``application.core.db_uri``.
|
||||
|
||||
DocsGPT has two Postgres connection strings — ``POSTGRES_URI`` (consumed
|
||||
by SQLAlchemy) and ``PGVECTOR_CONNECTION_STRING`` (consumed by
|
||||
``psycopg.connect()`` directly). They need opposite normalization
|
||||
because SQLAlchemy requires a ``postgresql+psycopg://`` dialect prefix
|
||||
and libpq rejects it. Each field has its own normalizer so operators
|
||||
can write whichever form feels natural and cross-pollination between
|
||||
the two fields is forgiven.
|
||||
|
||||
The normalizers live in ``application.core.db_uri`` as plain functions
|
||||
so these tests can exercise them directly without having to instantiate
|
||||
``Settings`` (which would pull in ``.env`` file side effects).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.db_uri import (
|
||||
normalize_pgvector_connection_string,
|
||||
normalize_postgres_uri,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizePostgresUri:
|
||||
@pytest.mark.parametrize(
|
||||
"input_value,expected",
|
||||
[
|
||||
# User-friendly forms get rewritten to the SQLAlchemy dialect.
|
||||
(
|
||||
"postgres://u:p@h:5432/d",
|
||||
"postgresql+psycopg://u:p@h:5432/d",
|
||||
),
|
||||
(
|
||||
"postgresql://u:p@h:5432/d",
|
||||
"postgresql+psycopg://u:p@h:5432/d",
|
||||
),
|
||||
# Legacy psycopg2 dialect is silently upgraded — psycopg2 is
|
||||
# no longer in requirements.txt, so there's no way it can work
|
||||
# as-is, and rewriting is friendlier than failing.
|
||||
(
|
||||
"postgresql+psycopg2://u:p@h:5432/d",
|
||||
"postgresql+psycopg://u:p@h:5432/d",
|
||||
),
|
||||
# Already-correct dialect passes through unchanged.
|
||||
(
|
||||
"postgresql+psycopg://u:p@h:5432/d",
|
||||
"postgresql+psycopg://u:p@h:5432/d",
|
||||
),
|
||||
# Whitespace is trimmed before rewriting.
|
||||
(
|
||||
" postgres://u:p@h/d ",
|
||||
"postgresql+psycopg://u:p@h/d",
|
||||
),
|
||||
# Query-string params (sslmode, options) are preserved verbatim.
|
||||
(
|
||||
"postgresql://u:p@h:5432/d?sslmode=require&application_name=docsgpt",
|
||||
"postgresql+psycopg://u:p@h:5432/d?sslmode=require&application_name=docsgpt",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rewrites_common_forms_to_psycopg_dialect(self, input_value, expected):
|
||||
assert normalize_postgres_uri(input_value) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_value",
|
||||
[None, "", " ", "None", "none"],
|
||||
)
|
||||
def test_empty_or_none_like_returns_none(self, input_value):
|
||||
assert normalize_postgres_uri(input_value) is None
|
||||
|
||||
def test_unknown_scheme_passes_through(self):
|
||||
"""A dialect we don't recognise is left alone so SQLAlchemy can
|
||||
produce its own error message when the engine tries to connect.
|
||||
Better than silently eating the config."""
|
||||
weird = "postgresql+asyncpg://u:p@h/d"
|
||||
assert normalize_postgres_uri(weird) == weird
|
||||
|
||||
def test_non_string_input_passes_through(self):
|
||||
"""Non-string inputs (e.g. if pydantic ever passes an int) shouldn't
|
||||
crash the normalizer — let pydantic's own type validation handle it."""
|
||||
assert normalize_postgres_uri(42) == 42 # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizePgvectorConnectionString:
|
||||
"""Symmetric to the POSTGRES_URI normalizer but pulls in the OPPOSITE
|
||||
direction: strips the SQLAlchemy dialect prefix so libpq accepts it.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_value,expected",
|
||||
[
|
||||
# User-friendly forms pass through — libpq accepts them natively.
|
||||
(
|
||||
"postgres://u:p@h:5432/d",
|
||||
"postgres://u:p@h:5432/d",
|
||||
),
|
||||
(
|
||||
"postgresql://u:p@h:5432/d",
|
||||
"postgresql://u:p@h:5432/d",
|
||||
),
|
||||
# SQLAlchemy dialect prefixes get stripped so libpq accepts them.
|
||||
# Operators hit this when they copy POSTGRES_URI → PGVECTOR_CONNECTION_STRING.
|
||||
(
|
||||
"postgresql+psycopg://u:p@h:5432/d",
|
||||
"postgresql://u:p@h:5432/d",
|
||||
),
|
||||
(
|
||||
"postgresql+psycopg2://u:p@h:5432/d",
|
||||
"postgresql://u:p@h:5432/d",
|
||||
),
|
||||
# Whitespace is trimmed before rewriting.
|
||||
(
|
||||
" postgresql+psycopg://u:p@h/d ",
|
||||
"postgresql://u:p@h/d",
|
||||
),
|
||||
# Query-string params (sslmode, etc.) are preserved verbatim.
|
||||
(
|
||||
"postgresql+psycopg://u:p@h:5432/d?sslmode=require",
|
||||
"postgresql://u:p@h:5432/d?sslmode=require",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rewrites_dialect_forms_to_libpq_compatible(self, input_value, expected):
|
||||
assert normalize_pgvector_connection_string(input_value) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_value",
|
||||
[None, "", " ", "None", "none"],
|
||||
)
|
||||
def test_empty_or_none_like_returns_none(self, input_value):
|
||||
assert normalize_pgvector_connection_string(input_value) is None
|
||||
|
||||
def test_unknown_scheme_passes_through(self):
|
||||
"""A scheme we don't recognise is left alone so libpq can produce
|
||||
its own error message when the connection is attempted."""
|
||||
weird = "mysql://u:p@h/d"
|
||||
assert normalize_pgvector_connection_string(weird) == weird
|
||||
|
||||
def test_non_string_input_passes_through(self):
|
||||
assert normalize_pgvector_connection_string(42) == 42 # type: ignore[arg-type]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Unit tests for the bounded-drain gunicorn worker."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core import shutdown
|
||||
from application.core.settings import settings
|
||||
from application.gunicorn_worker import (
|
||||
BoundedDrainUvicornWorker,
|
||||
_ShutdownAwareServer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_flag():
|
||||
shutdown.reset_shutdown()
|
||||
yield
|
||||
shutdown.reset_shutdown()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_worker_bounds_graceful_shutdown():
|
||||
cfg = BoundedDrainUvicornWorker.CONFIG_KWARGS
|
||||
assert cfg["timeout_graceful_shutdown"] == settings.GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS
|
||||
assert isinstance(cfg["timeout_graceful_shutdown"], int)
|
||||
# Must sit under the gunicorn --timeout (180) or the watchdog fires first.
|
||||
assert 0 < cfg["timeout_graceful_shutdown"] < 180
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_shutdown_raises_flag():
|
||||
from uvicorn.config import Config
|
||||
|
||||
async def _dummy_asgi(scope, receive, send): # pragma: no cover - never called
|
||||
pass
|
||||
|
||||
server = _ShutdownAwareServer(Config(app=_dummy_asgi))
|
||||
assert shutdown.is_shutting_down() is False
|
||||
|
||||
# Stub the heavy uvicorn drain so the test only exercises our override.
|
||||
with mock.patch("uvicorn.server.Server.shutdown", new=mock.AsyncMock()) as sup:
|
||||
await server.shutdown()
|
||||
|
||||
# The flag is raised before delegating to uvicorn's real shutdown.
|
||||
assert shutdown.is_shutting_down() is True
|
||||
sup.assert_awaited_once()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Unit tests for ``log_context`` + ``_ContextFilter``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core import log_context
|
||||
from application.core.logging_config import _ContextFilter
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_log_ctx():
|
||||
# The contextvar is module-scoped; snapshot at entry, restore at exit
|
||||
# to keep tests from leaking state into each other.
|
||||
token = log_context.bind()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
log_context.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBindAndSnapshot:
|
||||
|
||||
def test_bind_returns_token_and_snapshot_reflects_overlay(self):
|
||||
token = log_context.bind(activity_id="a1", user_id="u1")
|
||||
assert log_context.snapshot() == {"activity_id": "a1", "user_id": "u1"}
|
||||
log_context.reset(token)
|
||||
assert log_context.snapshot() == {}
|
||||
|
||||
def test_bind_drops_unknown_keys(self):
|
||||
token = log_context.bind(activity_id="a1", not_a_real_key="boom")
|
||||
try:
|
||||
assert log_context.snapshot() == {"activity_id": "a1"}
|
||||
finally:
|
||||
log_context.reset(token)
|
||||
|
||||
def test_bind_drops_none_values(self):
|
||||
token = log_context.bind(activity_id="a1", agent_id=None)
|
||||
try:
|
||||
assert "agent_id" not in log_context.snapshot()
|
||||
finally:
|
||||
log_context.reset(token)
|
||||
|
||||
def test_bind_coerces_values_to_str(self):
|
||||
token = log_context.bind(activity_id=42)
|
||||
try:
|
||||
assert log_context.snapshot()["activity_id"] == "42"
|
||||
finally:
|
||||
log_context.reset(token)
|
||||
|
||||
def test_nested_bind_overlays_and_resets_lifo(self):
|
||||
outer = log_context.bind(activity_id="outer", user_id="u1")
|
||||
inner = log_context.bind(activity_id="inner", agent_id="agent-1")
|
||||
# Inner overrides activity_id, keeps user_id from outer, adds agent_id.
|
||||
assert log_context.snapshot() == {
|
||||
"activity_id": "inner",
|
||||
"user_id": "u1",
|
||||
"agent_id": "agent-1",
|
||||
}
|
||||
log_context.reset(inner)
|
||||
assert log_context.snapshot() == {"activity_id": "outer", "user_id": "u1"}
|
||||
log_context.reset(outer)
|
||||
assert log_context.snapshot() == {}
|
||||
|
||||
def test_parent_activity_id_pattern(self):
|
||||
outer = log_context.bind(activity_id="parent-1")
|
||||
parent = log_context.snapshot().get("activity_id")
|
||||
inner = log_context.bind(activity_id="child-1", parent_activity_id=parent)
|
||||
try:
|
||||
snap = log_context.snapshot()
|
||||
assert snap["activity_id"] == "child-1"
|
||||
assert snap["parent_activity_id"] == "parent-1"
|
||||
finally:
|
||||
log_context.reset(inner)
|
||||
log_context.reset(outer)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestContextFilter:
|
||||
|
||||
def _make_record(self, **extra) -> logging.LogRecord:
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg="hello",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
for k, v in extra.items():
|
||||
setattr(record, k, v)
|
||||
return record
|
||||
|
||||
def test_stamps_record_with_context(self):
|
||||
token = log_context.bind(activity_id="a1", user_id="u1")
|
||||
try:
|
||||
record = self._make_record()
|
||||
assert _ContextFilter().filter(record) is True
|
||||
assert record.activity_id == "a1"
|
||||
assert record.user_id == "u1"
|
||||
finally:
|
||||
log_context.reset(token)
|
||||
|
||||
def test_explicit_extra_wins_over_context(self):
|
||||
# extra={} sets attributes on the record before the filter runs;
|
||||
# the filter must not overwrite them.
|
||||
token = log_context.bind(activity_id="from-ctx")
|
||||
try:
|
||||
record = self._make_record(activity_id="from-extra")
|
||||
_ContextFilter().filter(record)
|
||||
assert record.activity_id == "from-extra"
|
||||
finally:
|
||||
log_context.reset(token)
|
||||
|
||||
def test_no_op_when_context_empty(self):
|
||||
record = self._make_record()
|
||||
assert _ContextFilter().filter(record) is True
|
||||
assert not hasattr(record, "activity_id")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFilterWiringEndToEnd:
|
||||
"""Regression guard: the filter must be installed on handlers, not on
|
||||
loggers — Python skips logger-level filters during propagation.
|
||||
"""
|
||||
|
||||
def test_propagated_record_gets_stamped(self):
|
||||
from application.core.logging_config import _install_context_filter
|
||||
|
||||
captured: list[logging.LogRecord] = []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record):
|
||||
captured.append(record)
|
||||
|
||||
root = logging.getLogger()
|
||||
saved_handlers = list(root.handlers)
|
||||
saved_level = root.level
|
||||
try:
|
||||
root.handlers = [_Capture()]
|
||||
root.setLevel(logging.DEBUG)
|
||||
_install_context_filter()
|
||||
|
||||
child = logging.getLogger("test_log_context.propagation")
|
||||
child.setLevel(logging.DEBUG)
|
||||
|
||||
token = log_context.bind(activity_id="propagated-id")
|
||||
try:
|
||||
child.info("from a child logger")
|
||||
finally:
|
||||
log_context.reset(token)
|
||||
|
||||
assert captured, "Capture handler should have received the record"
|
||||
assert captured[0].activity_id == "propagated-id"
|
||||
finally:
|
||||
root.handlers = saved_handlers
|
||||
root.setLevel(saved_level)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for setup_logging — in particular the OTEL log-handler hand-off.
|
||||
|
||||
`opentelemetry-instrument` attaches an OTEL `LoggingHandler` to the root
|
||||
logger before our module-level `setup_logging()` runs in `application/app.py`.
|
||||
The default `dictConfig` call replaces `root.handlers`, which would silently
|
||||
drop the OTEL handler. setup_logging snapshots and re-attaches OTEL handlers
|
||||
when OTLP log export is enabled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.logging_config import setup_logging
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_root_logger():
|
||||
"""Snapshot/restore the root logger so tests don't leak handlers."""
|
||||
root = logging.getLogger()
|
||||
saved_handlers = list(root.handlers)
|
||||
saved_level = root.level
|
||||
yield
|
||||
root.handlers = saved_handlers
|
||||
root.setLevel(saved_level)
|
||||
|
||||
|
||||
def _make_fake_otel_handler() -> logging.Handler:
|
||||
"""Build a Handler whose class lives in a module starting with 'opentelemetry'.
|
||||
|
||||
Mirrors how the real `opentelemetry.sdk._logs.LoggingHandler` would be
|
||||
detected without needing the OTEL SDK installed in the test env.
|
||||
"""
|
||||
fake_module = types.ModuleType("opentelemetry.fake_sdk._logs")
|
||||
sys.modules.setdefault(fake_module.__name__, fake_module)
|
||||
|
||||
class FakeOtelHandler(logging.Handler):
|
||||
pass
|
||||
|
||||
FakeOtelHandler.__module__ = fake_module.__name__
|
||||
return FakeOtelHandler()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSetupLogging:
|
||||
|
||||
def test_default_keeps_only_console_handler(self, monkeypatch):
|
||||
monkeypatch.delenv("OTEL_LOGS_EXPORTER", raising=False)
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
logging.getLogger().handlers = []
|
||||
|
||||
setup_logging()
|
||||
|
||||
handlers = logging.getLogger().handlers
|
||||
assert len(handlers) == 1
|
||||
assert isinstance(handlers[0], logging.StreamHandler)
|
||||
|
||||
def test_preserves_otel_handler_when_otlp_logs_enabled(self, monkeypatch):
|
||||
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "otlp")
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
|
||||
otel_handler = _make_fake_otel_handler()
|
||||
logging.getLogger().handlers = [otel_handler]
|
||||
|
||||
setup_logging()
|
||||
|
||||
handlers = logging.getLogger().handlers
|
||||
assert otel_handler in handlers, (
|
||||
"OTEL handler must survive setup_logging when OTLP log export is on"
|
||||
)
|
||||
assert any(
|
||||
isinstance(h, logging.StreamHandler) and not isinstance(h, type(otel_handler))
|
||||
for h in handlers
|
||||
), "console handler should still be installed alongside the OTEL handler"
|
||||
|
||||
def test_does_not_preserve_when_sdk_disabled(self, monkeypatch):
|
||||
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "otlp")
|
||||
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
|
||||
|
||||
otel_handler = _make_fake_otel_handler()
|
||||
logging.getLogger().handlers = [otel_handler]
|
||||
|
||||
setup_logging()
|
||||
|
||||
handlers = logging.getLogger().handlers
|
||||
assert otel_handler not in handlers, (
|
||||
"When OTEL_SDK_DISABLED=true the handler should not be preserved"
|
||||
)
|
||||
|
||||
def test_does_not_preserve_when_logs_exporter_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("OTEL_LOGS_EXPORTER", raising=False)
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
|
||||
otel_handler = _make_fake_otel_handler()
|
||||
logging.getLogger().handlers = [otel_handler]
|
||||
|
||||
setup_logging()
|
||||
|
||||
handlers = logging.getLogger().handlers
|
||||
assert otel_handler not in handlers
|
||||
@@ -0,0 +1,295 @@
|
||||
"""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
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Tests for application/core/model_settings.py.
|
||||
|
||||
The provider-specific load logic that used to live in private
|
||||
``_add_<X>_models`` methods now lives in plugin classes under
|
||||
``application/llm/providers/`` and YAML catalogs under
|
||||
``application/core/models/``. End-to-end coverage of the registry +
|
||||
plugin pipeline is in ``tests/core/test_model_registry_yaml.py``.
|
||||
|
||||
This file covers the data classes (``AvailableModel``,
|
||||
``ModelCapabilities``, ``ModelProvider``) and the singleton/lookup
|
||||
contract on ``ModelRegistry``.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.model_settings import (
|
||||
AvailableModel,
|
||||
ModelCapabilities,
|
||||
ModelProvider,
|
||||
ModelRegistry,
|
||||
)
|
||||
|
||||
|
||||
class TestModelProvider:
|
||||
@pytest.mark.unit
|
||||
def test_all_providers_exist(self):
|
||||
assert ModelProvider.OPENAI == "openai"
|
||||
assert ModelProvider.ANTHROPIC == "anthropic"
|
||||
assert ModelProvider.GOOGLE == "google"
|
||||
assert ModelProvider.GROQ == "groq"
|
||||
assert ModelProvider.DOCSGPT == "docsgpt"
|
||||
assert ModelProvider.HUGGINGFACE == "huggingface"
|
||||
assert ModelProvider.NOVITA == "novita"
|
||||
assert ModelProvider.OPENROUTER == "openrouter"
|
||||
assert ModelProvider.SAGEMAKER == "sagemaker"
|
||||
assert ModelProvider.PREMAI == "premai"
|
||||
assert ModelProvider.LLAMA_CPP == "llama.cpp"
|
||||
|
||||
|
||||
class TestModelCapabilities:
|
||||
@pytest.mark.unit
|
||||
def test_defaults(self):
|
||||
caps = ModelCapabilities()
|
||||
assert caps.supports_tools is False
|
||||
assert caps.supports_structured_output is False
|
||||
assert caps.supports_streaming is True
|
||||
assert caps.supported_attachment_types == []
|
||||
assert caps.context_window == 128000
|
||||
assert caps.input_cost_per_token is None
|
||||
assert caps.output_cost_per_token is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_custom_values(self):
|
||||
caps = ModelCapabilities(
|
||||
supports_tools=True,
|
||||
supports_structured_output=True,
|
||||
context_window=32000,
|
||||
input_cost_per_token=0.001,
|
||||
)
|
||||
assert caps.supports_tools is True
|
||||
assert caps.context_window == 32000
|
||||
|
||||
|
||||
class TestAvailableModel:
|
||||
@pytest.mark.unit
|
||||
def test_to_dict_basic(self):
|
||||
model = AvailableModel(
|
||||
id="gpt-4",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="GPT-4",
|
||||
description="OpenAI GPT-4",
|
||||
)
|
||||
d = model.to_dict()
|
||||
assert d["id"] == "gpt-4"
|
||||
assert d["provider"] == "openai"
|
||||
assert d["display_name"] == "GPT-4"
|
||||
assert d["enabled"] is True
|
||||
assert "base_url" not in d
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_to_dict_with_base_url(self):
|
||||
model = AvailableModel(
|
||||
id="local-model",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="Local",
|
||||
base_url="http://localhost:11434/v1",
|
||||
)
|
||||
d = model.to_dict()
|
||||
assert d["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_to_dict_includes_capabilities(self):
|
||||
caps = ModelCapabilities(
|
||||
supports_tools=True,
|
||||
supports_structured_output=True,
|
||||
context_window=200000,
|
||||
supported_attachment_types=["image/png"],
|
||||
)
|
||||
model = AvailableModel(
|
||||
id="m",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="M",
|
||||
capabilities=caps,
|
||||
)
|
||||
d = model.to_dict()
|
||||
assert d["supports_tools"] is True
|
||||
assert d["supports_structured_output"] is True
|
||||
assert d["context_window"] == 200000
|
||||
assert d["supported_attachment_types"] == ["image/png"]
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_to_dict_disabled_model(self):
|
||||
model = AvailableModel(
|
||||
id="disabled",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="Disabled",
|
||||
enabled=False,
|
||||
)
|
||||
d = model.to_dict()
|
||||
assert d["enabled"] is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_api_key_field_never_serialized(self):
|
||||
"""Forward-compat hook: AvailableModel.api_key (reserved for the
|
||||
future end-user BYOM phase) must never leak into the wire format."""
|
||||
model = AvailableModel(
|
||||
id="byom",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="BYOM",
|
||||
api_key="secret-key-do-not-leak",
|
||||
)
|
||||
d = model.to_dict()
|
||||
assert "api_key" not in d
|
||||
for v in d.values():
|
||||
assert v != "secret-key-do-not-leak"
|
||||
|
||||
|
||||
class TestModelRegistryPublicAPI:
|
||||
"""Covers the public lookup contract. Loading behavior is exercised
|
||||
end-to-end in tests/core/test_model_registry_yaml.py."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_singleton(self):
|
||||
ModelRegistry.reset()
|
||||
yield
|
||||
ModelRegistry.reset()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_singleton(self):
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
r1 = ModelRegistry()
|
||||
r2 = ModelRegistry()
|
||||
assert r1 is r2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_instance(self):
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
r = ModelRegistry.get_instance()
|
||||
assert isinstance(r, ModelRegistry)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_model(self):
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
reg = ModelRegistry()
|
||||
model = AvailableModel(
|
||||
id="test", provider=ModelProvider.OPENAI, display_name="Test"
|
||||
)
|
||||
reg.models["test"] = model
|
||||
assert reg.get_model("test") is model
|
||||
assert reg.get_model("nonexistent") is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_all_models(self):
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
reg = ModelRegistry()
|
||||
reg.models["m1"] = AvailableModel(
|
||||
id="m1", provider=ModelProvider.OPENAI, display_name="M1"
|
||||
)
|
||||
reg.models["m2"] = AvailableModel(
|
||||
id="m2", provider=ModelProvider.ANTHROPIC, display_name="M2"
|
||||
)
|
||||
assert len(reg.get_all_models()) == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_enabled_models(self):
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
reg = ModelRegistry()
|
||||
reg.models["m1"] = AvailableModel(
|
||||
id="m1",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="M1",
|
||||
enabled=True,
|
||||
)
|
||||
reg.models["m2"] = AvailableModel(
|
||||
id="m2",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="M2",
|
||||
enabled=False,
|
||||
)
|
||||
enabled = reg.get_enabled_models()
|
||||
assert len(enabled) == 1
|
||||
assert enabled[0].id == "m1"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_exists(self):
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
reg = ModelRegistry()
|
||||
reg.models["m1"] = AvailableModel(
|
||||
id="m1", provider=ModelProvider.OPENAI, display_name="M1"
|
||||
)
|
||||
assert reg.model_exists("m1") is True
|
||||
assert reg.model_exists("m2") is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_lookups_accept_user_id_kwarg(self):
|
||||
"""Reserved for the future end-user BYOM phase. Currently ignored."""
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
reg = ModelRegistry()
|
||||
reg.models["m1"] = AvailableModel(
|
||||
id="m1", provider=ModelProvider.OPENAI, display_name="M1"
|
||||
)
|
||||
assert reg.get_model("m1", user_id="alice") is not None
|
||||
assert reg.model_exists("m1", user_id="alice") is True
|
||||
assert len(reg.get_all_models(user_id="alice")) == 1
|
||||
assert len(reg.get_enabled_models(user_id="alice")) == 1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reset(self):
|
||||
with patch.object(ModelRegistry, "_load_models"):
|
||||
r1 = ModelRegistry()
|
||||
ModelRegistry.reset()
|
||||
r2 = ModelRegistry()
|
||||
assert r1 is not r2
|
||||
@@ -0,0 +1,372 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.model_settings import (
|
||||
AvailableModel,
|
||||
ModelCapabilities,
|
||||
ModelProvider,
|
||||
ModelRegistry,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
"""Reset ModelRegistry singleton between tests."""
|
||||
ModelRegistry._instance = None
|
||||
ModelRegistry._initialized = False
|
||||
yield
|
||||
ModelRegistry._instance = None
|
||||
ModelRegistry._initialized = False
|
||||
|
||||
|
||||
def _make_model(
|
||||
model_id="test-model",
|
||||
provider=ModelProvider.OPENAI,
|
||||
display_name="Test Model",
|
||||
context_window=128000,
|
||||
supports_tools=True,
|
||||
supports_structured_output=False,
|
||||
supported_attachment_types=None,
|
||||
enabled=True,
|
||||
base_url=None,
|
||||
):
|
||||
return AvailableModel(
|
||||
id=model_id,
|
||||
provider=provider,
|
||||
display_name=display_name,
|
||||
capabilities=ModelCapabilities(
|
||||
supports_tools=supports_tools,
|
||||
supports_structured_output=supports_structured_output,
|
||||
supported_attachment_types=supported_attachment_types or [],
|
||||
context_window=context_window,
|
||||
),
|
||||
enabled=enabled,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
|
||||
# ── get_api_key_for_provider ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetApiKeyForProvider:
|
||||
"""settings is lazily imported inside the function body, so we patch
|
||||
at application.core.settings.settings (the actual module attribute)."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_openai_key(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.OPENAI_API_KEY = "sk-openai"
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
mock_settings.OPEN_ROUTER_API_KEY = None
|
||||
mock_settings.NOVITA_API_KEY = None
|
||||
mock_settings.ANTHROPIC_API_KEY = None
|
||||
mock_settings.GOOGLE_API_KEY = None
|
||||
mock_settings.GROQ_API_KEY = None
|
||||
mock_settings.HUGGINGFACE_API_KEY = None
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("openai") == "sk-openai"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_anthropic_key(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.ANTHROPIC_API_KEY = "sk-anthropic"
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("anthropic") == "sk-anthropic"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_google_key(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.GOOGLE_API_KEY = "sk-google"
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("google") == "sk-google"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_groq_key(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.GROQ_API_KEY = "sk-groq"
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("groq") == "sk-groq"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_openrouter_key(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.OPEN_ROUTER_API_KEY = "sk-or"
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("openrouter") == "sk-or"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_novita_key(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.NOVITA_API_KEY = "sk-novita"
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("novita") == "sk-novita"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_huggingface_key(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.HUGGINGFACE_API_KEY = "hf-key"
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("huggingface") == "hf-key"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_docsgpt_returns_fallback(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("docsgpt") == "sk-fallback"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_llama_cpp_returns_fallback(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("llama.cpp") == "sk-fallback"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unknown_provider_returns_fallback(self):
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.API_KEY = "sk-fallback"
|
||||
|
||||
from application.core.model_utils import get_api_key_for_provider
|
||||
|
||||
assert get_api_key_for_provider("unknown_provider") == "sk-fallback"
|
||||
|
||||
# ── get_all_available_models ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetAllAvailableModels:
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_returns_enabled_models_as_dict(self, mock_get_instance):
|
||||
model_a = _make_model("model-a", display_name="Model A")
|
||||
model_b = _make_model("model-b", display_name="Model B")
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_enabled_models.return_value = [model_a, model_b]
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_all_available_models
|
||||
|
||||
result = get_all_available_models()
|
||||
|
||||
assert "model-a" in result
|
||||
assert "model-b" in result
|
||||
assert result["model-a"]["display_name"] == "Model A"
|
||||
assert result["model-b"]["display_name"] == "Model B"
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_empty_registry(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_enabled_models.return_value = []
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_all_available_models
|
||||
|
||||
assert get_all_available_models() == {}
|
||||
|
||||
|
||||
# ── validate_model_id ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateModelId:
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_exists(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.model_exists.return_value = True
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import validate_model_id
|
||||
|
||||
assert validate_model_id("gpt-4") is True
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_not_exists(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.model_exists.return_value = False
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import validate_model_id
|
||||
|
||||
assert validate_model_id("nonexistent") is False
|
||||
|
||||
|
||||
# ── get_model_capabilities ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetModelCapabilities:
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_found(self, mock_get_instance):
|
||||
model = _make_model(
|
||||
"gpt-4",
|
||||
context_window=8192,
|
||||
supports_tools=True,
|
||||
supports_structured_output=True,
|
||||
supported_attachment_types=["image/png"],
|
||||
)
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = model
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_model_capabilities
|
||||
|
||||
caps = get_model_capabilities("gpt-4")
|
||||
|
||||
assert caps is not None
|
||||
assert caps["supported_attachment_types"] == ["image/png"]
|
||||
assert caps["supports_tools"] is True
|
||||
assert caps["supports_structured_output"] is True
|
||||
assert caps["context_window"] == 8192
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_not_found(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = None
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_model_capabilities
|
||||
|
||||
assert get_model_capabilities("nonexistent") is None
|
||||
|
||||
|
||||
# ── get_default_model_id ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetDefaultModelId:
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_returns_default(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.default_model_id = "gpt-4"
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_default_model_id
|
||||
|
||||
assert get_default_model_id() == "gpt-4"
|
||||
|
||||
|
||||
# ── get_provider_from_model_id ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetProviderFromModelId:
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_found(self, mock_get_instance):
|
||||
model = _make_model("gpt-4", provider=ModelProvider.OPENAI)
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = model
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_provider_from_model_id
|
||||
|
||||
assert get_provider_from_model_id("gpt-4") == "openai"
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_not_found(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = None
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_provider_from_model_id
|
||||
|
||||
assert get_provider_from_model_id("nonexistent") is None
|
||||
|
||||
|
||||
# ── get_token_limit ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTokenLimit:
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_found(self, mock_get_instance):
|
||||
model = _make_model("gpt-4", context_window=8192)
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = model
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_token_limit
|
||||
|
||||
assert get_token_limit("gpt-4") == 8192
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_not_found_returns_default(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = None
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
with patch("application.core.settings.settings") as mock_settings:
|
||||
mock_settings.DEFAULT_LLM_TOKEN_LIMIT = 128000
|
||||
|
||||
from application.core.model_utils import get_token_limit
|
||||
|
||||
assert get_token_limit("nonexistent") == 128000
|
||||
|
||||
|
||||
# ── get_base_url_for_model ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetBaseUrlForModel:
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_with_base_url(self, mock_get_instance):
|
||||
model = _make_model("custom-model", base_url="http://localhost:8080")
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = model
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_base_url_for_model
|
||||
|
||||
assert get_base_url_for_model("custom-model") == "http://localhost:8080"
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_without_base_url(self, mock_get_instance):
|
||||
model = _make_model("gpt-4", base_url=None)
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = model
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_base_url_for_model
|
||||
|
||||
assert get_base_url_for_model("gpt-4") is None
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("application.core.model_utils.ModelRegistry.get_instance")
|
||||
def test_model_not_found(self, mock_get_instance):
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_model.return_value = None
|
||||
mock_get_instance.return_value = mock_registry
|
||||
|
||||
from application.core.model_utils import get_base_url_for_model
|
||||
|
||||
assert get_base_url_for_model("nonexistent") is None
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Tests for the operator MODELS_CONFIG_DIR.
|
||||
|
||||
Covers the operator-supplied directory of model YAMLs that's loaded
|
||||
after the built-in catalog. Operators use this to add new
|
||||
``openai_compatible`` providers, extend an existing provider's catalog
|
||||
with extra models, or override a built-in model's capabilities — all
|
||||
without forking the repo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from textwrap import dedent
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.model_registry import ModelRegistry
|
||||
|
||||
|
||||
def _make_settings(**overrides):
|
||||
s = MagicMock()
|
||||
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():
|
||||
ModelRegistry.reset()
|
||||
yield
|
||||
ModelRegistry.reset()
|
||||
|
||||
|
||||
# ── New provider via openai_compatible ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOperatorAddsNewProvider:
|
||||
def test_drop_in_yaml_appears_in_registry(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
(tmp_path / "fireworks.yaml").write_text(dedent("""
|
||||
provider: openai_compatible
|
||||
display_provider: fireworks
|
||||
api_key_env: FIREWORKS_API_KEY
|
||||
base_url: https://api.fireworks.ai/inference/v1
|
||||
defaults:
|
||||
supports_tools: true
|
||||
models:
|
||||
- id: accounts/fireworks/models/llama-v3p3-70b-instruct
|
||||
display_name: Llama 3.3 70B (Fireworks)
|
||||
"""))
|
||||
monkeypatch.setenv("FIREWORKS_API_KEY", "fw-key")
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
m = reg.get_model("accounts/fireworks/models/llama-v3p3-70b-instruct")
|
||||
assert m is not None
|
||||
assert m.api_key == "fw-key"
|
||||
assert m.base_url == "https://api.fireworks.ai/inference/v1"
|
||||
assert m.display_provider == "fireworks"
|
||||
|
||||
|
||||
# ── Extending an existing provider's catalog ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOperatorExtendsExistingProvider:
|
||||
def test_operator_adds_anthropic_model_to_builtin_catalog(
|
||||
self, tmp_path
|
||||
):
|
||||
(tmp_path / "anthropic-extra.yaml").write_text(dedent("""
|
||||
provider: anthropic
|
||||
defaults:
|
||||
supports_tools: true
|
||||
context_window: 200000
|
||||
models:
|
||||
- id: claude-haiku-5-0-future
|
||||
display_name: Claude Haiku 5.0
|
||||
"""))
|
||||
|
||||
s = _make_settings(
|
||||
ANTHROPIC_API_KEY="sk-ant",
|
||||
MODELS_CONFIG_DIR=str(tmp_path),
|
||||
)
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
# Built-in models still present
|
||||
assert reg.get_model("claude-sonnet-4-6") is not None
|
||||
assert reg.get_model("claude-opus-4-7") is not None
|
||||
# Operator-added model also present
|
||||
added = reg.get_model("claude-haiku-5-0-future")
|
||||
assert added is not None
|
||||
assert added.display_name == "Claude Haiku 5.0"
|
||||
|
||||
|
||||
# ── Overriding a built-in model's capabilities ───────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOperatorOverridesBuiltinCapabilities:
|
||||
def test_operator_yaml_overrides_builtin_context_window(
|
||||
self, tmp_path, caplog
|
||||
):
|
||||
# Override anthropic claude-haiku-4-5 to claim a 1M context window
|
||||
(tmp_path / "anthropic-override.yaml").write_text(dedent("""
|
||||
provider: anthropic
|
||||
defaults:
|
||||
supports_tools: true
|
||||
attachments: [image]
|
||||
context_window: 1000000
|
||||
models:
|
||||
- id: claude-haiku-4-5
|
||||
display_name: Claude Haiku 4.5 (extended)
|
||||
description: Operator-overridden capabilities
|
||||
"""))
|
||||
|
||||
s = _make_settings(
|
||||
ANTHROPIC_API_KEY="sk-ant",
|
||||
MODELS_CONFIG_DIR=str(tmp_path),
|
||||
)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
m = reg.get_model("claude-haiku-4-5")
|
||||
assert m.display_name == "Claude Haiku 4.5 (extended)"
|
||||
assert m.description == "Operator-overridden capabilities"
|
||||
assert m.capabilities.context_window == 1_000_000
|
||||
|
||||
# And the override warning fires so the operator can audit it
|
||||
assert any(
|
||||
"claude-haiku-4-5" in rec.message and "redefined" in rec.message
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
# ── Misconfigured MODELS_CONFIG_DIR ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMisconfiguredOperatorDir:
|
||||
def test_missing_dir_logs_warning_and_continues(
|
||||
self, tmp_path, caplog
|
||||
):
|
||||
bogus = tmp_path / "does-not-exist"
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(bogus))
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
# Built-in catalog still loaded
|
||||
assert reg.get_model("docsgpt-local") is not None
|
||||
# And the operator was warned
|
||||
assert any("does not exist" in rec.message for rec in caplog.records)
|
||||
|
||||
def test_path_is_a_file_logs_warning(self, tmp_path, caplog):
|
||||
afile = tmp_path / "not-a-dir.yaml"
|
||||
afile.write_text("provider: anthropic\nmodels: []")
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(afile))
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
assert reg.get_model("docsgpt-local") is not None
|
||||
assert any("not a directory" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
# ── Validation: unknown provider rejected ────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOperatorValidation:
|
||||
def test_unknown_provider_in_operator_yaml_aborts_boot(self, tmp_path):
|
||||
(tmp_path / "bogus.yaml").write_text(dedent("""
|
||||
provider: not_a_real_provider
|
||||
models:
|
||||
- id: x
|
||||
display_name: X
|
||||
"""))
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
ModelRegistry()
|
||||
# Could be ModelYAMLError (enum check) or ValueError (registry check);
|
||||
# either way the message must surface what's wrong.
|
||||
msg = str(exc_info.value)
|
||||
assert "not_a_real_provider" in msg
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Tests for the openai_compatible provider.
|
||||
|
||||
Covers YAML loading from a temp directory, multiple coexisting catalogs
|
||||
(Mistral + Together), env-var-based credential resolution, the legacy
|
||||
OPENAI_BASE_URL + LLM_NAME fallback, and end-to-end model dispatch
|
||||
through LLMCreator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.model_registry import ModelRegistry
|
||||
from application.core.model_settings import ModelProvider
|
||||
|
||||
|
||||
def _make_settings(**overrides):
|
||||
s = MagicMock()
|
||||
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
|
||||
|
||||
|
||||
def _write_mistral_yaml(directory: Path) -> Path:
|
||||
path = directory / "mistral.yaml"
|
||||
path.write_text(dedent("""
|
||||
provider: openai_compatible
|
||||
display_provider: mistral
|
||||
api_key_env: MISTRAL_API_KEY
|
||||
base_url: https://api.mistral.ai/v1
|
||||
defaults:
|
||||
supports_tools: true
|
||||
context_window: 128000
|
||||
models:
|
||||
- id: mistral-large-latest
|
||||
display_name: Mistral Large
|
||||
- id: mistral-small-latest
|
||||
display_name: Mistral Small
|
||||
"""))
|
||||
return path
|
||||
|
||||
|
||||
def _write_together_yaml(directory: Path) -> Path:
|
||||
path = directory / "together.yaml"
|
||||
path.write_text(dedent("""
|
||||
provider: openai_compatible
|
||||
display_provider: together
|
||||
api_key_env: TOGETHER_API_KEY
|
||||
base_url: https://api.together.xyz/v1
|
||||
defaults:
|
||||
supports_tools: true
|
||||
models:
|
||||
- id: meta-llama/Llama-3.3-70B-Instruct-Turbo
|
||||
display_name: Llama 3.3 70B (Together)
|
||||
"""))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry(monkeypatch):
|
||||
# Builtin openai_compatible catalogs (e.g. deepseek.yaml) activate when
|
||||
# their api_key_env is present in os.environ, which the openai_compatible
|
||||
# provider reads directly, independent of the patched settings. Clear it
|
||||
# so a key leaked by another test (or present in the dev .env) doesn't add
|
||||
# extra models to the exact-match assertions below. Mirrors the delenv
|
||||
# guard in test_model_registry_yaml.py.
|
||||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||||
ModelRegistry.reset()
|
||||
yield
|
||||
ModelRegistry.reset()
|
||||
|
||||
|
||||
# ── YAML-driven catalogs ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestYAMLCompatibleProvider:
|
||||
def test_mistral_yaml_loads_with_env_key(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
_write_mistral_yaml(tmp_path)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test")
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
m = reg.get_model("mistral-large-latest")
|
||||
assert m is not None
|
||||
assert m.provider == ModelProvider.OPENAI_COMPATIBLE
|
||||
assert m.display_provider == "mistral"
|
||||
assert m.base_url == "https://api.mistral.ai/v1"
|
||||
assert m.api_key == "sk-mistral-test"
|
||||
assert m.capabilities.supports_tools is True
|
||||
assert m.capabilities.context_window == 128000
|
||||
|
||||
def test_yaml_skipped_when_env_var_missing(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
_write_mistral_yaml(tmp_path)
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
# Catalog skipped when no key — no Mistral models in the registry
|
||||
assert reg.get_model("mistral-large-latest") is None
|
||||
|
||||
def test_two_compatible_catalogs_coexist_with_separate_keys(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
_write_mistral_yaml(tmp_path)
|
||||
_write_together_yaml(tmp_path)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral")
|
||||
monkeypatch.setenv("TOGETHER_API_KEY", "sk-together")
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
mistral = reg.get_model("mistral-large-latest")
|
||||
together = reg.get_model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||
|
||||
assert mistral.api_key == "sk-mistral"
|
||||
assert mistral.base_url == "https://api.mistral.ai/v1"
|
||||
assert mistral.display_provider == "mistral"
|
||||
|
||||
assert together.api_key == "sk-together"
|
||||
assert together.base_url == "https://api.together.xyz/v1"
|
||||
assert together.display_provider == "together"
|
||||
|
||||
def test_one_catalog_enabled_other_skipped(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
_write_mistral_yaml(tmp_path)
|
||||
_write_together_yaml(tmp_path)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral")
|
||||
monkeypatch.delenv("TOGETHER_API_KEY", raising=False)
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
assert reg.get_model("mistral-large-latest") is not None
|
||||
assert reg.get_model("meta-llama/Llama-3.3-70B-Instruct-Turbo") is None
|
||||
|
||||
def test_missing_base_url_raises(self, tmp_path, monkeypatch):
|
||||
bad = tmp_path / "broken.yaml"
|
||||
bad.write_text(dedent("""
|
||||
provider: openai_compatible
|
||||
api_key_env: SOME_KEY
|
||||
models:
|
||||
- id: x
|
||||
display_name: X
|
||||
"""))
|
||||
monkeypatch.setenv("SOME_KEY", "k")
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
with pytest.raises(ValueError, match="must set 'base_url'"):
|
||||
ModelRegistry()
|
||||
|
||||
def test_missing_api_key_env_raises(self, tmp_path, monkeypatch):
|
||||
bad = tmp_path / "broken.yaml"
|
||||
bad.write_text(dedent("""
|
||||
provider: openai_compatible
|
||||
base_url: https://x/v1
|
||||
models:
|
||||
- id: x
|
||||
display_name: X
|
||||
"""))
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
with pytest.raises(ValueError, match="must set 'api_key_env'"):
|
||||
ModelRegistry()
|
||||
|
||||
def test_to_dict_uses_display_provider(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
_write_mistral_yaml(tmp_path)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk")
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
|
||||
d = reg.get_model("mistral-large-latest").to_dict()
|
||||
# /api/models response shows "mistral", not "openai_compatible"
|
||||
assert d["provider"] == "mistral"
|
||||
# api_key never leaks into the wire format
|
||||
assert "api_key" not in d
|
||||
for v in d.values():
|
||||
assert v != "sk"
|
||||
|
||||
|
||||
# ── Legacy OPENAI_BASE_URL fallback ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLegacyOpenAIBaseURLPath:
|
||||
def test_legacy_models_now_provided_by_openai_compatible(self):
|
||||
s = _make_settings(
|
||||
OPENAI_BASE_URL="http://localhost:11434/v1",
|
||||
OPENAI_API_KEY="sk-local",
|
||||
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()}
|
||||
assert ids == {"llama3", "gemma"}
|
||||
|
||||
llama = reg.get_model("llama3")
|
||||
assert llama.base_url == "http://localhost:11434/v1"
|
||||
assert llama.api_key == "sk-local"
|
||||
assert llama.provider == ModelProvider.OPENAI_COMPATIBLE
|
||||
# Display provider preserves the historical "openai" label
|
||||
assert llama.display_provider == "openai"
|
||||
assert llama.to_dict()["provider"] == "openai"
|
||||
|
||||
def test_legacy_uses_api_key_fallback_when_openai_api_key_missing(self):
|
||||
s = _make_settings(
|
||||
OPENAI_BASE_URL="http://localhost:11434/v1",
|
||||
OPENAI_API_KEY=None,
|
||||
API_KEY="sk-generic",
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_NAME="llama3",
|
||||
)
|
||||
with patch("application.core.settings.settings", s):
|
||||
reg = ModelRegistry()
|
||||
assert reg.get_model("llama3").api_key == "sk-generic"
|
||||
|
||||
|
||||
# ── Dispatch through LLMCreator ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLLMCreatorDispatch:
|
||||
def test_llmcreator_uses_per_model_api_key_and_base_url(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""End-to-end: when an openai_compatible model is dispatched, the
|
||||
per-model api_key + base_url from the registry must override
|
||||
whatever the caller passed."""
|
||||
_write_mistral_yaml(tmp_path)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-real")
|
||||
|
||||
s = _make_settings(MODELS_CONFIG_DIR=str(tmp_path))
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeLLM:
|
||||
def __init__(
|
||||
self, api_key, user_api_key, *args, **kwargs
|
||||
):
|
||||
captured["api_key"] = api_key
|
||||
captured["base_url"] = kwargs.get("base_url")
|
||||
captured["model_id"] = kwargs.get("model_id")
|
||||
|
||||
with patch("application.core.settings.settings", s):
|
||||
ModelRegistry.reset()
|
||||
ModelRegistry() # warm up the registry under patched settings
|
||||
|
||||
# Now patch the OpenAI plugin's class so we can capture the
|
||||
# constructor args without spinning up the real OpenAILLM.
|
||||
from application.llm.providers import PROVIDERS_BY_NAME
|
||||
|
||||
with patch.object(
|
||||
PROVIDERS_BY_NAME["openai_compatible"],
|
||||
"llm_class",
|
||||
_FakeLLM,
|
||||
):
|
||||
from application.llm.llm_creator import LLMCreator
|
||||
|
||||
LLMCreator.create_llm(
|
||||
type="openai_compatible",
|
||||
api_key="caller-passed-WRONG-key",
|
||||
user_api_key=None,
|
||||
decoded_token={"sub": "u"},
|
||||
model_id="mistral-large-latest",
|
||||
)
|
||||
|
||||
assert captured["api_key"] == "sk-mistral-real"
|
||||
assert captured["base_url"] == "https://api.mistral.ai/v1"
|
||||
assert captured["model_id"] == "mistral-large-latest"
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Tests for the BYOM per-user layer on ModelRegistry.
|
||||
|
||||
Covers: per-user lookups don't leak across users, lookups without
|
||||
user_id stay built-in only, get_all_models / get_enabled_models /
|
||||
model_exists all consult the user layer when given user_id, and the
|
||||
explicit invalidate_user clears the cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core.model_registry import ModelRegistry
|
||||
from application.core.model_settings import ModelProvider
|
||||
from application.storage.db.repositories.user_custom_models import (
|
||||
UserCustomModelsRepository,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
ModelRegistry.reset()
|
||||
yield
|
||||
ModelRegistry.reset()
|
||||
|
||||
|
||||
def _make_settings(**overrides):
|
||||
s = MagicMock()
|
||||
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
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _yield(conn):
|
||||
yield conn
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPerUserLayer:
|
||||
def test_user_models_isolated_per_user(self, pg_conn):
|
||||
"""Alice's BYOM model must not appear in Bob's lookups."""
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
alice_model = repo.create(
|
||||
user_id="alice",
|
||||
upstream_model_id="alice-mistral",
|
||||
display_name="Alice Mistral",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="sk-alice",
|
||||
)
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
assert reg.get_model(alice_model["id"], user_id="alice") is not None
|
||||
assert reg.get_model(alice_model["id"], user_id="bob") is None
|
||||
# And without a user_id at all, the per-user layer is invisible
|
||||
assert reg.get_model(alice_model["id"]) is None
|
||||
|
||||
def test_get_all_models_includes_user_models(self, pg_conn):
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="mistral-large-latest",
|
||||
display_name="My Mistral",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="sk-test",
|
||||
)
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
ids_no_user = {m.id for m in reg.get_all_models()}
|
||||
ids_with_user = {
|
||||
m.id for m in reg.get_all_models(user_id="user-1")
|
||||
}
|
||||
assert created["id"] not in ids_no_user
|
||||
assert created["id"] in ids_with_user
|
||||
|
||||
def test_user_models_carry_decrypted_api_key_and_upstream_id(self, pg_conn):
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="mistral-large-latest",
|
||||
display_name="My Mistral",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="sk-test-XYZ",
|
||||
)
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
m = reg.get_model(created["id"], user_id="user-1")
|
||||
|
||||
assert m is not None
|
||||
assert m.provider == ModelProvider.OPENAI_COMPATIBLE
|
||||
assert m.upstream_model_id == "mistral-large-latest"
|
||||
assert m.api_key == "sk-test-XYZ"
|
||||
assert m.base_url == "https://api.mistral.ai/v1"
|
||||
assert m.source == "user"
|
||||
# The wire format never leaks the api_key
|
||||
d = m.to_dict()
|
||||
assert "api_key" not in d
|
||||
for v in d.values():
|
||||
assert v != "sk-test-XYZ"
|
||||
|
||||
def test_invalidate_user_clears_cache(self, pg_conn):
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="x",
|
||||
display_name="X",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="k",
|
||||
)
|
||||
|
||||
s = _make_settings()
|
||||
# Stub Redis so invalidate_user can publish its version bump
|
||||
# without hitting a real broker. The P1 fix calls ``incr`` on
|
||||
# invalidate; here we just need it not to raise.
|
||||
fake_redis = MagicMock()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
), patch(
|
||||
"application.cache.get_redis_instance", return_value=fake_redis
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
assert reg.get_model(created["id"], user_id="user-1") is not None
|
||||
# Cache populated
|
||||
assert "user-1" in reg._user_models
|
||||
ModelRegistry.invalidate_user("user-1")
|
||||
assert "user-1" not in reg._user_models
|
||||
# Re-lookup repopulates
|
||||
reg.get_model(created["id"], user_id="user-1")
|
||||
assert "user-1" in reg._user_models
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLLMCreatorDispatchUsesUpstreamModelId:
|
||||
def test_llmcreator_sends_upstream_id_not_uuid(self, pg_conn):
|
||||
"""End-to-end: LLMCreator with a BYOM uuid must construct the
|
||||
OpenAILLM with the user's upstream model name (e.g.
|
||||
``mistral-large-latest``), not the registry uuid."""
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="mistral-large-latest",
|
||||
display_name="My Mistral",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="sk-mistral-real",
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeLLM:
|
||||
def __init__(self, api_key, user_api_key, *args, **kwargs):
|
||||
captured["api_key"] = api_key
|
||||
captured["base_url"] = kwargs.get("base_url")
|
||||
captured["model_id"] = kwargs.get("model_id")
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
):
|
||||
ModelRegistry()
|
||||
from application.llm.providers import PROVIDERS_BY_NAME
|
||||
|
||||
with patch.object(
|
||||
PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
|
||||
):
|
||||
from application.llm.llm_creator import LLMCreator
|
||||
|
||||
LLMCreator.create_llm(
|
||||
type="openai_compatible",
|
||||
api_key="caller-passed-WRONG",
|
||||
user_api_key=None,
|
||||
decoded_token={"sub": "user-1"},
|
||||
model_id=created["id"],
|
||||
)
|
||||
|
||||
assert captured["api_key"] == "sk-mistral-real"
|
||||
assert captured["base_url"] == "https://api.mistral.ai/v1"
|
||||
assert captured["model_id"] == "mistral-large-latest" # NOT the uuid!
|
||||
|
||||
def test_llmcreator_forwards_byom_capabilities(self, pg_conn):
|
||||
"""LLMCreator must thread the registry-resolved ``capabilities``
|
||||
into the LLM. Without it the OpenAILLM hard-codes ``True`` for
|
||||
tools/structured output and advertises image attachments
|
||||
unconditionally, leaking unsupported features to BYOMs that
|
||||
disabled them."""
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-2",
|
||||
upstream_model_id="my-text-only-model",
|
||||
display_name="Text-only BYOM",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="sk-real",
|
||||
capabilities={
|
||||
"supports_tools": False,
|
||||
"supports_structured_output": False,
|
||||
"attachments": [],
|
||||
"context_window": 8192,
|
||||
},
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeLLM:
|
||||
def __init__(self, api_key, user_api_key, *args, **kwargs):
|
||||
captured["capabilities"] = kwargs.get("capabilities")
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
):
|
||||
ModelRegistry()
|
||||
from application.llm.providers import PROVIDERS_BY_NAME
|
||||
|
||||
with patch.object(
|
||||
PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
|
||||
):
|
||||
from application.llm.llm_creator import LLMCreator
|
||||
|
||||
LLMCreator.create_llm(
|
||||
type="openai_compatible",
|
||||
api_key="ignored",
|
||||
user_api_key=None,
|
||||
decoded_token={"sub": "user-2"},
|
||||
model_id=created["id"],
|
||||
)
|
||||
|
||||
caps = captured["capabilities"]
|
||||
assert caps is not None
|
||||
assert caps.supports_tools is False
|
||||
assert caps.supports_structured_output is False
|
||||
assert caps.supported_attachment_types == []
|
||||
|
||||
def test_byom_image_alias_expands_to_mime_types(self, pg_conn):
|
||||
"""A BYOM stored with ``attachments: ["image"]`` (the alias the
|
||||
UI sends) must surface as concrete MIME types on the registry
|
||||
record, matching the built-in YAML expansion. Without this,
|
||||
handlers/base.prepare_messages compares ``image/png`` against
|
||||
the bare alias and filters every image upload as unsupported.
|
||||
"""
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="my-vision-model",
|
||||
display_name="Vision BYOM",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="sk-real",
|
||||
capabilities={"attachments": ["image"]},
|
||||
)
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
model = reg.get_model(created["id"], user_id="user-1")
|
||||
|
||||
assert model is not None
|
||||
types = model.capabilities.supported_attachment_types
|
||||
assert "image" not in types, (
|
||||
"alias must be expanded, not stored verbatim"
|
||||
)
|
||||
assert any(t.startswith("image/") for t in types)
|
||||
# Must include at least the common web image types so any image
|
||||
# an end user uploads has a chance to match.
|
||||
assert "image/png" in types
|
||||
assert "image/jpeg" in types
|
||||
|
||||
def test_byom_unknown_alias_is_skipped_at_runtime(self, pg_conn):
|
||||
"""Operator alias-map edits could orphan a stored alias. The
|
||||
registry must drop the unknown entry rather than the entire
|
||||
layer (which would hide every BYOM the user has).
|
||||
"""
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="m",
|
||||
display_name="M",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="k",
|
||||
# Bypass the route validation: write a bad alias straight
|
||||
# to the row to simulate the post-edit orphan case.
|
||||
capabilities={"attachments": ["image", "not-a-real-alias"]},
|
||||
)
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
model = reg.get_model(created["id"], user_id="user-1")
|
||||
|
||||
assert model is not None
|
||||
types = model.capabilities.supported_attachment_types
|
||||
assert "not-a-real-alias" not in types
|
||||
assert any(t.startswith("image/") for t in types)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCrossProcessInvalidation:
|
||||
"""The BYOM cache lives per-process. Without the P1 fix, a CRUD on
|
||||
web-1 would leave the decrypted record (with old api_key/base_url)
|
||||
cached forever in web-2 / Celery. These tests pin down that:
|
||||
|
||||
* ``invalidate_user`` publishes a version bump to Redis
|
||||
* peers reload when the version they saw at load time is stale
|
||||
* the local TTL bounds staleness even when Redis is unreachable
|
||||
* unchanged version + expired TTL extends the entry without a
|
||||
DB read (the common-case fast path)
|
||||
"""
|
||||
|
||||
def test_invalidate_user_publishes_redis_version_bump(self, pg_conn):
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="m1",
|
||||
display_name="M1",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="k",
|
||||
)
|
||||
|
||||
fake_redis = MagicMock()
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
), patch(
|
||||
"application.cache.get_redis_instance", return_value=fake_redis
|
||||
):
|
||||
ModelRegistry().get_model("anything", user_id="user-1")
|
||||
ModelRegistry.invalidate_user("user-1")
|
||||
|
||||
fake_redis.incr.assert_called_once_with("byom:registry_version:user-1")
|
||||
|
||||
def test_peer_reloads_when_redis_version_changes(self, pg_conn):
|
||||
"""Two-process simulation. Peer loads at version=0; another
|
||||
process's CRUD bumps the version and updates Postgres; peer's
|
||||
next post-TTL access sees the version mismatch and reloads,
|
||||
picking up the rotated key it never invalidated locally."""
|
||||
from application.core import model_registry as registry_mod
|
||||
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="m-orig",
|
||||
display_name="orig",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="orig-key",
|
||||
)
|
||||
|
||||
state = {"version": 0}
|
||||
|
||||
class _FakeRedis:
|
||||
def get(self, key):
|
||||
if key == "byom:registry_version:user-1":
|
||||
return str(state["version"]).encode()
|
||||
return None
|
||||
|
||||
def incr(self, key):
|
||||
if key == "byom:registry_version:user-1":
|
||||
state["version"] += 1
|
||||
|
||||
s = _make_settings()
|
||||
# Force TTL to 0 so any subsequent access takes the post-TTL
|
||||
# path without waiting 60s.
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
), patch(
|
||||
"application.cache.get_redis_instance", return_value=_FakeRedis()
|
||||
), patch.object(
|
||||
registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
assert (
|
||||
reg.get_model(created["id"], user_id="user-1").api_key
|
||||
== "orig-key"
|
||||
)
|
||||
|
||||
# Another process's CRUD: bump Redis counter + mutate the
|
||||
# row. Note we deliberately do NOT call ``invalidate_user``
|
||||
# in this process — that's the whole point of the test.
|
||||
state["version"] += 1
|
||||
repo.update(
|
||||
created["id"],
|
||||
"user-1",
|
||||
{"api_key_plaintext": "rotated-key"},
|
||||
)
|
||||
|
||||
assert (
|
||||
reg.get_model(created["id"], user_id="user-1").api_key
|
||||
== "rotated-key"
|
||||
)
|
||||
|
||||
def test_ttl_bounds_staleness_when_redis_unavailable(self, pg_conn):
|
||||
"""Redis down → fall back to TTL-only invalidation. After the
|
||||
TTL elapses, peers reload regardless."""
|
||||
from application.core import model_registry as registry_mod
|
||||
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="m",
|
||||
display_name="m",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="orig",
|
||||
)
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
lambda: _yield(pg_conn),
|
||||
), patch(
|
||||
"application.cache.get_redis_instance", return_value=None
|
||||
), patch.object(
|
||||
registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
first = reg.get_model(created["id"], user_id="user-1")
|
||||
assert first.api_key == "orig"
|
||||
|
||||
repo.update(
|
||||
created["id"],
|
||||
"user-1",
|
||||
{"api_key_plaintext": "rotated"},
|
||||
)
|
||||
|
||||
second = reg.get_model(created["id"], user_id="user-1")
|
||||
assert second.api_key == "rotated"
|
||||
|
||||
def test_unchanged_version_extends_ttl_without_db_read(self, pg_conn):
|
||||
"""Hot path: TTL expires but Redis says no invalidation
|
||||
happened — extend the entry without re-reading Postgres."""
|
||||
from application.core import model_registry as registry_mod
|
||||
|
||||
repo = UserCustomModelsRepository(pg_conn)
|
||||
created = repo.create(
|
||||
user_id="user-1",
|
||||
upstream_model_id="m",
|
||||
display_name="m",
|
||||
base_url="https://api.mistral.ai/v1",
|
||||
api_key_plaintext="k",
|
||||
)
|
||||
|
||||
fake_redis = MagicMock()
|
||||
fake_redis.get.return_value = b"7" # constant version
|
||||
|
||||
db_open_count = {"n": 0}
|
||||
|
||||
@contextmanager
|
||||
def _counting_db_readonly():
|
||||
db_open_count["n"] += 1
|
||||
yield pg_conn
|
||||
|
||||
s = _make_settings()
|
||||
with patch("application.core.settings.settings", s), patch(
|
||||
"application.storage.db.session.db_readonly",
|
||||
_counting_db_readonly,
|
||||
), patch(
|
||||
"application.cache.get_redis_instance", return_value=fake_redis
|
||||
), patch.object(
|
||||
registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
|
||||
):
|
||||
reg = ModelRegistry()
|
||||
reg.get_model(created["id"], user_id="user-1")
|
||||
first_open = db_open_count["n"]
|
||||
# TTL has expired, but Redis returns the same version we
|
||||
# captured at load time → no DB reload.
|
||||
reg.get_model(created["id"], user_id="user-1")
|
||||
reg.get_model(created["id"], user_id="user-1")
|
||||
assert db_open_count["n"] == first_open
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Unit tests for the process-wide graceful-shutdown flag."""
|
||||
|
||||
import pytest
|
||||
|
||||
from application.core import shutdown
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_flag():
|
||||
shutdown.reset_shutdown()
|
||||
yield
|
||||
shutdown.reset_shutdown()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_flag_starts_clear():
|
||||
assert shutdown.is_shutting_down() is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_begin_shutdown_sets_flag():
|
||||
shutdown.begin_shutdown()
|
||||
assert shutdown.is_shutting_down() is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_begin_shutdown_is_idempotent():
|
||||
shutdown.begin_shutdown()
|
||||
shutdown.begin_shutdown()
|
||||
assert shutdown.is_shutting_down() is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reset_clears_flag():
|
||||
shutdown.begin_shutdown()
|
||||
shutdown.reset_shutdown()
|
||||
assert shutdown.is_shutting_down() is False
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Tests for SSRF URL validation module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from application.core.url_validation import (
|
||||
SSRFError,
|
||||
validate_url,
|
||||
validate_url_safe,
|
||||
is_private_ip,
|
||||
is_metadata_ip,
|
||||
)
|
||||
|
||||
|
||||
class TestIsPrivateIP:
|
||||
"""Tests for is_private_ip function."""
|
||||
|
||||
def test_loopback_ipv4(self):
|
||||
assert is_private_ip("127.0.0.1") is True
|
||||
assert is_private_ip("127.255.255.255") is True
|
||||
|
||||
def test_private_class_a(self):
|
||||
assert is_private_ip("10.0.0.1") is True
|
||||
assert is_private_ip("10.255.255.255") is True
|
||||
|
||||
def test_private_class_b(self):
|
||||
assert is_private_ip("172.16.0.1") is True
|
||||
assert is_private_ip("172.31.255.255") is True
|
||||
|
||||
def test_private_class_c(self):
|
||||
assert is_private_ip("192.168.0.1") is True
|
||||
assert is_private_ip("192.168.255.255") is True
|
||||
|
||||
def test_link_local(self):
|
||||
assert is_private_ip("169.254.0.1") is True
|
||||
|
||||
def test_public_ip(self):
|
||||
assert is_private_ip("8.8.8.8") is False
|
||||
assert is_private_ip("1.1.1.1") is False
|
||||
assert is_private_ip("93.184.216.34") is False
|
||||
|
||||
def test_invalid_ip(self):
|
||||
assert is_private_ip("not-an-ip") is False
|
||||
assert is_private_ip("") is False
|
||||
|
||||
|
||||
class TestIsMetadataIP:
|
||||
"""Tests for is_metadata_ip function."""
|
||||
|
||||
def test_aws_metadata_ip(self):
|
||||
assert is_metadata_ip("169.254.169.254") is True
|
||||
|
||||
def test_aws_ecs_metadata_ip(self):
|
||||
assert is_metadata_ip("169.254.170.2") is True
|
||||
|
||||
def test_non_metadata_ip(self):
|
||||
assert is_metadata_ip("8.8.8.8") is False
|
||||
assert is_metadata_ip("10.0.0.1") is False
|
||||
|
||||
|
||||
class TestValidateUrl:
|
||||
"""Tests for validate_url function."""
|
||||
|
||||
def test_adds_scheme_if_missing(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "93.184.216.34" # Public IP
|
||||
result = validate_url("example.com")
|
||||
assert result == "http://example.com"
|
||||
|
||||
def test_preserves_https_scheme(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "93.184.216.34"
|
||||
result = validate_url("https://example.com")
|
||||
assert result == "https://example.com"
|
||||
|
||||
def test_blocks_localhost(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://localhost")
|
||||
assert "localhost" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_localhost_localdomain(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://localhost.localdomain")
|
||||
assert "not allowed" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_loopback_ip(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://127.0.0.1")
|
||||
assert "private" in str(exc_info.value).lower() or "internal" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_private_ip_class_a(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://10.0.0.1")
|
||||
assert "private" in str(exc_info.value).lower() or "internal" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_private_ip_class_b(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://172.16.0.1")
|
||||
assert "private" in str(exc_info.value).lower() or "internal" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_private_ip_class_c(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://192.168.1.1")
|
||||
assert "private" in str(exc_info.value).lower() or "internal" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_aws_metadata_ip(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://169.254.169.254")
|
||||
assert "metadata" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_aws_metadata_with_path(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://169.254.169.254/latest/meta-data/")
|
||||
assert "metadata" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_gcp_metadata_hostname(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://metadata.google.internal")
|
||||
assert "not allowed" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_ftp_scheme(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("ftp://example.com")
|
||||
assert "scheme" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_file_scheme(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("file:///etc/passwd")
|
||||
assert "scheme" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_hostname_resolving_to_private_ip(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "192.168.1.1"
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://internal.example.com")
|
||||
assert "private" in str(exc_info.value).lower() or "internal" in str(exc_info.value).lower()
|
||||
|
||||
def test_blocks_hostname_resolving_to_metadata_ip(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "169.254.169.254"
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://evil.example.com")
|
||||
assert "metadata" in str(exc_info.value).lower()
|
||||
|
||||
def test_allows_public_ip(self):
|
||||
result = validate_url("http://8.8.8.8")
|
||||
assert result == "http://8.8.8.8"
|
||||
|
||||
def test_allows_public_hostname(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "93.184.216.34"
|
||||
result = validate_url("https://example.com")
|
||||
assert result == "https://example.com"
|
||||
|
||||
def test_raises_on_unresolvable_hostname(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = None
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://nonexistent.invalid")
|
||||
assert "resolve" in str(exc_info.value).lower()
|
||||
|
||||
def test_raises_on_empty_hostname(self):
|
||||
with pytest.raises(SSRFError) as exc_info:
|
||||
validate_url("http://")
|
||||
assert "hostname" in str(exc_info.value).lower()
|
||||
|
||||
def test_allow_localhost_flag(self):
|
||||
# Should work with allow_localhost=True
|
||||
result = validate_url("http://localhost", allow_localhost=True)
|
||||
assert result == "http://localhost"
|
||||
|
||||
result = validate_url("http://127.0.0.1", allow_localhost=True)
|
||||
assert result == "http://127.0.0.1"
|
||||
|
||||
|
||||
class TestValidateUrlSafe:
|
||||
"""Tests for validate_url_safe non-throwing function."""
|
||||
|
||||
def test_returns_tuple_on_success(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "93.184.216.34"
|
||||
is_valid, url, error = validate_url_safe("https://example.com")
|
||||
assert is_valid is True
|
||||
assert url == "https://example.com"
|
||||
assert error is None
|
||||
|
||||
def test_returns_tuple_on_failure(self):
|
||||
is_valid, url, error = validate_url_safe("http://localhost")
|
||||
assert is_valid is False
|
||||
assert url == "http://localhost"
|
||||
assert error is not None
|
||||
assert "localhost" in error.lower()
|
||||
|
||||
def test_returns_error_message_for_private_ip(self):
|
||||
is_valid, url, error = validate_url_safe("http://192.168.1.1")
|
||||
assert is_valid is False
|
||||
assert "private" in error.lower() or "internal" in error.lower()
|
||||
|
||||
def test_adds_scheme_when_missing(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "93.184.216.34"
|
||||
is_valid, url, error = validate_url_safe("example.com")
|
||||
assert is_valid is True
|
||||
assert url == "http://example.com"
|
||||
|
||||
|
||||
class TestIsPrivateIPExtended:
|
||||
"""Additional edge cases for IP classification."""
|
||||
|
||||
def test_multicast_ip(self):
|
||||
assert is_private_ip("224.0.0.1") is True
|
||||
|
||||
def test_unspecified_ip(self):
|
||||
assert is_private_ip("0.0.0.0") is True
|
||||
|
||||
def test_ipv6_loopback(self):
|
||||
assert is_private_ip("::1") is True
|
||||
|
||||
def test_ipv6_private(self):
|
||||
assert is_private_ip("fc00::1") is True
|
||||
|
||||
def test_ipv6_public(self):
|
||||
assert is_private_ip("2607:f8b0:4004:800::200e") is False
|
||||
|
||||
def test_reserved_ip(self):
|
||||
# 240.0.0.0/4 is reserved (future use), Python's ipaddress marks it as such
|
||||
assert is_private_ip("240.0.0.1") is True
|
||||
|
||||
|
||||
class TestValidateUrlExtended:
|
||||
"""Additional URL validation tests."""
|
||||
|
||||
def test_blocks_metadata_hostname(self):
|
||||
with pytest.raises(SSRFError):
|
||||
validate_url("http://metadata")
|
||||
|
||||
def test_allows_localhost_with_flag(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "192.168.1.1"
|
||||
result = validate_url(
|
||||
"http://internal.local", allow_localhost=True
|
||||
)
|
||||
assert result == "http://internal.local"
|
||||
|
||||
def test_blocks_aws_ecs_metadata_ip(self):
|
||||
with pytest.raises(SSRFError, match="metadata"):
|
||||
validate_url("http://169.254.170.2")
|
||||
|
||||
def test_blocks_aws_ipv6_metadata(self):
|
||||
with pytest.raises(SSRFError, match="metadata"):
|
||||
validate_url("http://[fd00:ec2::254]")
|
||||
|
||||
def test_blocks_hostname_resolving_to_loopback(self):
|
||||
with patch("application.core.url_validation.resolve_hostname") as mock_resolve:
|
||||
mock_resolve.return_value = "127.0.0.1"
|
||||
with pytest.raises(SSRFError):
|
||||
validate_url("http://sneaky.example.com")
|
||||
|
||||
def test_allows_localhost_ip_with_flag(self):
|
||||
result = validate_url("http://10.0.0.1", allow_localhost=True)
|
||||
assert result == "http://10.0.0.1"
|
||||
Reference in New Issue
Block a user