mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-16 21:05:20 +02:00
feat(database-migrations): add migration to remove legacy model config tables and remove stale model connection code
This commit is contained in:
parent
50668775f8
commit
bd4a04f2e7
93 changed files with 956 additions and 11442 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"""Lock the runtime model-policy backstop in ``build_dependencies``.
|
||||
|
||||
Automations resolve their LLM from the *captured* ``agent_llm_id`` snapshot (so
|
||||
Automations resolve their LLM from the *captured* ``chat_model_id`` snapshot (so
|
||||
runs are insulated from later chat/search-space model changes), and the model
|
||||
policy is re-checked at run time so a captured model that is no longer billable
|
||||
fails the run clearly. When no snapshot is present, resolution falls back to the
|
||||
|
|
@ -45,10 +45,10 @@ def patched_side_effects(monkeypatch: pytest.MonkeyPatch):
|
|||
return None
|
||||
|
||||
|
||||
async def test_build_dependencies_resolves_captured_agent_llm_id(
|
||||
async def test_build_dependencies_resolves_captured_chat_model_id(
|
||||
monkeypatch: pytest.MonkeyPatch, patched_side_effects
|
||||
) -> None:
|
||||
"""The bundle loads with the *captured* ``agent_llm_id``, not the live search space."""
|
||||
"""The bundle loads with the *captured* ``chat_model_id``, not the live search space."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _fake_load(_session, *, config_id, search_space_id):
|
||||
|
|
@ -67,13 +67,13 @@ async def test_build_dependencies_resolves_captured_agent_llm_id(
|
|||
lambda _ss: pytest.fail("search-space policy should not run on captured path"),
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(agent_llm_id=-99)
|
||||
search_space = SimpleNamespace(chat_model_id=-99)
|
||||
result = await build_dependencies(
|
||||
session=_FakeSession(search_space),
|
||||
search_space_id=42,
|
||||
agent_llm_id=-7,
|
||||
image_generation_config_id=5,
|
||||
vision_llm_config_id=-1,
|
||||
chat_model_id=-7,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
|
||||
assert captured == {"config_id": -7, "search_space_id": 42}
|
||||
|
|
@ -98,17 +98,17 @@ async def test_build_dependencies_validates_captured_ids(
|
|||
monkeypatch.setattr(deps_mod, "load_llm_bundle", _fake_load)
|
||||
|
||||
await build_dependencies(
|
||||
session=_FakeSession(SimpleNamespace(agent_llm_id=0)),
|
||||
session=_FakeSession(SimpleNamespace(chat_model_id=0)),
|
||||
search_space_id=42,
|
||||
agent_llm_id=-7,
|
||||
image_generation_config_id=5,
|
||||
vision_llm_config_id=-1,
|
||||
chat_model_id=-7,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
|
||||
assert seen == {
|
||||
"agent_llm_id": -7,
|
||||
"image_generation_config_id": 5,
|
||||
"vision_llm_config_id": -1,
|
||||
"chat_model_id": -7,
|
||||
"image_gen_model_id": 5,
|
||||
"vision_model_id": -1,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ async def test_build_dependencies_raises_on_captured_policy_violation(
|
|||
|
||||
def _raise(**_kw):
|
||||
raise AutomationModelPolicyError(
|
||||
[{"kind": "image", "config_id": -2, "reason": "free model"}]
|
||||
[{"kind": "image", "model_id": -2, "reason": "free model"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(deps_mod, "assert_models_billable", _raise)
|
||||
|
|
@ -131,11 +131,11 @@ async def test_build_dependencies_raises_on_captured_policy_violation(
|
|||
|
||||
with pytest.raises(DependencyError):
|
||||
await build_dependencies(
|
||||
session=_FakeSession(SimpleNamespace(agent_llm_id=-7)),
|
||||
session=_FakeSession(SimpleNamespace(chat_model_id=-7)),
|
||||
search_space_id=42,
|
||||
agent_llm_id=-7,
|
||||
image_generation_config_id=-2,
|
||||
vision_llm_config_id=-1,
|
||||
chat_model_id=-7,
|
||||
image_gen_model_id=-2,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ async def test_build_dependencies_falls_back_to_search_space(
|
|||
lambda **_kw: pytest.fail("captured policy should not run on fallback path"),
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(agent_llm_id=-7)
|
||||
search_space = SimpleNamespace(chat_model_id=-7)
|
||||
result = await build_dependencies(
|
||||
session=_FakeSession(search_space), search_space_id=42
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def _run() -> SimpleNamespace:
|
|||
def test_build_action_ctx_propagates_captured_models() -> None:
|
||||
"""``definition.models`` flows onto the ActionContext model fields."""
|
||||
models = AutomationModels(
|
||||
agent_llm_id=-1,
|
||||
image_generation_config_id=5,
|
||||
vision_llm_config_id=-1,
|
||||
chat_model_id=-1,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
ctx = _build_action_ctx(
|
||||
cast(AsyncSession, None),
|
||||
|
|
@ -40,9 +40,9 @@ def test_build_action_ctx_propagates_captured_models() -> None:
|
|||
)
|
||||
|
||||
assert ctx.search_space_id == 42
|
||||
assert ctx.agent_llm_id == -1
|
||||
assert ctx.image_generation_config_id == 5
|
||||
assert ctx.vision_llm_config_id == -1
|
||||
assert ctx.chat_model_id == -1
|
||||
assert ctx.image_gen_model_id == 5
|
||||
assert ctx.vision_model_id == -1
|
||||
|
||||
|
||||
def test_build_action_ctx_none_models_leaves_fields_none() -> None:
|
||||
|
|
@ -54,6 +54,6 @@ def test_build_action_ctx_none_models_leaves_fields_none() -> None:
|
|||
None,
|
||||
)
|
||||
|
||||
assert ctx.agent_llm_id is None
|
||||
assert ctx.image_generation_config_id is None
|
||||
assert ctx.vision_llm_config_id is None
|
||||
assert ctx.chat_model_id is None
|
||||
assert ctx.image_gen_model_id is None
|
||||
assert ctx.vision_model_id is None
|
||||
|
|
|
|||
|
|
@ -40,24 +40,24 @@ def test_automation_definition_models_round_trip() -> None:
|
|||
name="Daily digest",
|
||||
plan=[PlanStep(step_id="s1", action="agent_task")],
|
||||
models=AutomationModels(
|
||||
agent_llm_id=-1,
|
||||
image_generation_config_id=5,
|
||||
vision_llm_config_id=-1,
|
||||
chat_model_id=-1,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
),
|
||||
)
|
||||
|
||||
dumped = definition.model_dump(mode="json", by_alias=True)
|
||||
assert dumped["models"] == {
|
||||
"agent_llm_id": -1,
|
||||
"image_generation_config_id": 5,
|
||||
"vision_llm_config_id": -1,
|
||||
"chat_model_id": -1,
|
||||
"image_gen_model_id": 5,
|
||||
"vision_model_id": -1,
|
||||
}
|
||||
|
||||
restored = AutomationDefinition.model_validate(dumped)
|
||||
assert restored.models is not None
|
||||
assert restored.models.agent_llm_id == -1
|
||||
assert restored.models.image_generation_config_id == 5
|
||||
assert restored.models.vision_llm_config_id == -1
|
||||
assert restored.models.chat_model_id == -1
|
||||
assert restored.models.image_gen_model_id == 5
|
||||
assert restored.models.vision_model_id == -1
|
||||
|
||||
|
||||
def test_automation_definition_rejects_unknown_top_level_field() -> None:
|
||||
|
|
|
|||
|
|
@ -64,12 +64,12 @@ async def test_assert_models_billable_raises_422_on_violation(
|
|||
|
||||
def _raise(_ss):
|
||||
raise AutomationModelPolicyError(
|
||||
[{"kind": "llm", "config_id": 0, "reason": "Auto mode"}]
|
||||
[{"kind": "llm", "model_id": 0, "reason": "Auto mode"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(automation_mod, "assert_automation_models_billable", _raise)
|
||||
|
||||
service = _service(SimpleNamespace(agent_llm_id=0))
|
||||
service = _service(SimpleNamespace(chat_model_id=0))
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service._assert_models_billable(1)
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ async def test_assert_models_billable_returns_search_space_when_ok(
|
|||
automation_mod, "assert_automation_models_billable", lambda _ss: None
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(agent_llm_id=-1)
|
||||
search_space = SimpleNamespace(chat_model_id=-1)
|
||||
service = _service(search_space)
|
||||
assert await service._assert_models_billable(1) is search_space
|
||||
|
||||
|
|
@ -123,9 +123,9 @@ async def test_create_injects_captured_models_from_search_space(
|
|||
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
|
||||
|
||||
search_space = SimpleNamespace(
|
||||
agent_llm_id=-1,
|
||||
image_generation_config_id=5,
|
||||
vision_llm_config_id=-1,
|
||||
chat_model_id=-1,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
service = _service(search_space)
|
||||
payload = AutomationCreate(
|
||||
|
|
@ -137,9 +137,9 @@ async def test_create_injects_captured_models_from_search_space(
|
|||
automation = await service.create(payload)
|
||||
|
||||
assert automation.definition["models"] == {
|
||||
"agent_llm_id": -1,
|
||||
"image_generation_config_id": 5,
|
||||
"vision_llm_config_id": -1,
|
||||
"chat_model_id": -1,
|
||||
"image_gen_model_id": 5,
|
||||
"vision_model_id": -1,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -162,9 +162,9 @@ async def test_create_treats_unset_prefs_as_auto_zero(
|
|||
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
|
||||
|
||||
search_space = SimpleNamespace(
|
||||
agent_llm_id=None,
|
||||
image_generation_config_id=None,
|
||||
vision_llm_config_id=None,
|
||||
chat_model_id=None,
|
||||
image_gen_model_id=None,
|
||||
vision_model_id=None,
|
||||
)
|
||||
service = _service(search_space)
|
||||
payload = AutomationCreate(search_space_id=1, name="A", definition=_definition())
|
||||
|
|
@ -172,9 +172,9 @@ async def test_create_treats_unset_prefs_as_auto_zero(
|
|||
automation = await service.create(payload)
|
||||
|
||||
assert automation.definition["models"] == {
|
||||
"agent_llm_id": 0,
|
||||
"image_generation_config_id": 0,
|
||||
"vision_llm_config_id": 0,
|
||||
"chat_model_id": 0,
|
||||
"image_gen_model_id": 0,
|
||||
"vision_model_id": 0,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -195,11 +195,11 @@ async def test_create_honors_selected_models_when_provided(
|
|||
)
|
||||
validated: dict[str, Any] = {}
|
||||
|
||||
def _assert_ok(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
|
||||
def _assert_ok(*, chat_model_id, image_gen_model_id, vision_model_id):
|
||||
validated["ids"] = (
|
||||
agent_llm_id,
|
||||
image_generation_config_id,
|
||||
vision_llm_config_id,
|
||||
chat_model_id,
|
||||
image_gen_model_id,
|
||||
vision_model_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(automation_mod, "assert_models_billable", _assert_ok)
|
||||
|
|
@ -213,15 +213,15 @@ async def test_create_honors_selected_models_when_provided(
|
|||
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
|
||||
monkeypatch.setattr(AutomationService, "_get_with_triggers_or_raise", _return_added)
|
||||
|
||||
service = _service(SimpleNamespace(agent_llm_id=-99))
|
||||
service = _service(SimpleNamespace(chat_model_id=-99))
|
||||
payload = AutomationCreate(
|
||||
search_space_id=1,
|
||||
name="A",
|
||||
definition=_definition(
|
||||
models=AutomationModels(
|
||||
agent_llm_id=-1,
|
||||
image_generation_config_id=7,
|
||||
vision_llm_config_id=-2,
|
||||
chat_model_id=-1,
|
||||
image_gen_model_id=7,
|
||||
vision_model_id=-2,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -230,9 +230,9 @@ async def test_create_honors_selected_models_when_provided(
|
|||
|
||||
assert validated["ids"] == (-1, 7, -2)
|
||||
assert automation.definition["models"] == {
|
||||
"agent_llm_id": -1,
|
||||
"image_generation_config_id": 7,
|
||||
"vision_llm_config_id": -2,
|
||||
"chat_model_id": -1,
|
||||
"image_gen_model_id": 7,
|
||||
"vision_model_id": -2,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -241,9 +241,9 @@ async def test_create_rejects_unbillable_selected_models(
|
|||
) -> None:
|
||||
"""A non-billable explicit selection maps the policy error to HTTP 422."""
|
||||
|
||||
def _raise(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
|
||||
def _raise(*, chat_model_id, image_gen_model_id, vision_model_id):
|
||||
raise AutomationModelPolicyError(
|
||||
[{"kind": "llm", "config_id": -3, "reason": "free model"}]
|
||||
[{"kind": "llm", "model_id": -3, "reason": "free model"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(automation_mod, "assert_models_billable", _raise)
|
||||
|
|
@ -253,15 +253,15 @@ async def test_create_rejects_unbillable_selected_models(
|
|||
|
||||
monkeypatch.setattr(AutomationService, "_authorize", _noop_authorize)
|
||||
|
||||
service = _service(SimpleNamespace(agent_llm_id=-3))
|
||||
service = _service(SimpleNamespace(chat_model_id=-3))
|
||||
payload = AutomationCreate(
|
||||
search_space_id=1,
|
||||
name="A",
|
||||
definition=_definition(
|
||||
models=AutomationModels(
|
||||
agent_llm_id=-3,
|
||||
image_generation_config_id=7,
|
||||
vision_llm_config_id=-2,
|
||||
chat_model_id=-3,
|
||||
image_gen_model_id=7,
|
||||
vision_model_id=-2,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -277,9 +277,9 @@ async def test_update_preserves_captured_models(
|
|||
) -> None:
|
||||
"""A definition edit carries over the previously captured ``models``."""
|
||||
captured = {
|
||||
"agent_llm_id": -1,
|
||||
"image_generation_config_id": 5,
|
||||
"vision_llm_config_id": -1,
|
||||
"chat_model_id": -1,
|
||||
"image_gen_model_id": 5,
|
||||
"vision_model_id": -1,
|
||||
}
|
||||
existing = SimpleNamespace(
|
||||
search_space_id=1,
|
||||
|
|
@ -318,20 +318,20 @@ async def test_update_honors_changed_models_when_valid(
|
|||
"name": "A",
|
||||
"plan": [],
|
||||
"models": {
|
||||
"agent_llm_id": -1,
|
||||
"image_generation_config_id": 5,
|
||||
"vision_llm_config_id": -1,
|
||||
"chat_model_id": -1,
|
||||
"image_gen_model_id": 5,
|
||||
"vision_model_id": -1,
|
||||
},
|
||||
},
|
||||
version=3,
|
||||
)
|
||||
validated: dict[str, Any] = {}
|
||||
|
||||
def _assert_ok(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
|
||||
def _assert_ok(*, chat_model_id, image_gen_model_id, vision_model_id):
|
||||
validated["ids"] = (
|
||||
agent_llm_id,
|
||||
image_generation_config_id,
|
||||
vision_llm_config_id,
|
||||
chat_model_id,
|
||||
image_gen_model_id,
|
||||
vision_model_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(automation_mod, "assert_models_billable", _assert_ok)
|
||||
|
|
@ -351,9 +351,9 @@ async def test_update_honors_changed_models_when_valid(
|
|||
patch = AutomationUpdate(
|
||||
definition=_definition(
|
||||
models=AutomationModels(
|
||||
agent_llm_id=-2,
|
||||
image_generation_config_id=9,
|
||||
vision_llm_config_id=-2,
|
||||
chat_model_id=-2,
|
||||
image_gen_model_id=9,
|
||||
vision_model_id=-2,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -362,9 +362,9 @@ async def test_update_honors_changed_models_when_valid(
|
|||
|
||||
assert validated["ids"] == (-2, 9, -2)
|
||||
assert result.definition["models"] == {
|
||||
"agent_llm_id": -2,
|
||||
"image_generation_config_id": 9,
|
||||
"vision_llm_config_id": -2,
|
||||
"chat_model_id": -2,
|
||||
"image_gen_model_id": 9,
|
||||
"vision_model_id": -2,
|
||||
}
|
||||
assert result.version == 4
|
||||
|
||||
|
|
@ -379,17 +379,17 @@ async def test_update_rejects_changed_unbillable_models(
|
|||
"name": "A",
|
||||
"plan": [],
|
||||
"models": {
|
||||
"agent_llm_id": -1,
|
||||
"image_generation_config_id": 5,
|
||||
"vision_llm_config_id": -1,
|
||||
"chat_model_id": -1,
|
||||
"image_gen_model_id": 5,
|
||||
"vision_model_id": -1,
|
||||
},
|
||||
},
|
||||
version=3,
|
||||
)
|
||||
|
||||
def _raise(*, agent_llm_id, image_generation_config_id, vision_llm_config_id):
|
||||
def _raise(*, chat_model_id, image_gen_model_id, vision_model_id):
|
||||
raise AutomationModelPolicyError(
|
||||
[{"kind": "llm", "config_id": -7, "reason": "free model"}]
|
||||
[{"kind": "llm", "model_id": -7, "reason": "free model"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(automation_mod, "assert_models_billable", _raise)
|
||||
|
|
@ -409,9 +409,9 @@ async def test_update_rejects_changed_unbillable_models(
|
|||
patch = AutomationUpdate(
|
||||
definition=_definition(
|
||||
models=AutomationModels(
|
||||
agent_llm_id=-7,
|
||||
image_generation_config_id=5,
|
||||
vision_llm_config_id=-1,
|
||||
chat_model_id=-7,
|
||||
image_gen_model_id=5,
|
||||
vision_model_id=-1,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -431,9 +431,9 @@ async def test_update_keeps_unchanged_models_without_revalidation(
|
|||
premium without an unrelated edit tripping the policy check.
|
||||
"""
|
||||
captured = {
|
||||
"agent_llm_id": -1,
|
||||
"image_generation_config_id": 5,
|
||||
"vision_llm_config_id": -1,
|
||||
"chat_model_id": -1,
|
||||
"image_gen_model_id": 5,
|
||||
"vision_model_id": -1,
|
||||
}
|
||||
existing = SimpleNamespace(
|
||||
search_space_id=1,
|
||||
|
|
@ -485,7 +485,7 @@ async def test_model_eligibility_authorizes_and_returns_payload(
|
|||
lambda _ss: {"allowed": False, "violations": [{"kind": "image"}]},
|
||||
)
|
||||
|
||||
service = _service(SimpleNamespace(agent_llm_id=-2))
|
||||
service = _service(SimpleNamespace(chat_model_id=-2))
|
||||
result = await service.model_eligibility(search_space_id=5)
|
||||
|
||||
assert result == {"allowed": False, "violations": [{"kind": "image"}]}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ pytestmark = pytest.mark.unit
|
|||
def _search_space(*, llm: int | None, image: int | None, vision: int | None):
|
||||
"""Minimal stand-in for the ``SearchSpace`` ORM row the policy reads."""
|
||||
return SimpleNamespace(
|
||||
agent_llm_id=llm,
|
||||
image_generation_config_id=image,
|
||||
vision_llm_config_id=vision,
|
||||
chat_model_id=llm,
|
||||
image_gen_model_id=image,
|
||||
vision_model_id=vision,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -39,29 +39,11 @@ def patched_globals(monkeypatch: pytest.MonkeyPatch):
|
|||
|
||||
Negative ids: -1 is premium, -2 is free, for each of llm/image/vision.
|
||||
"""
|
||||
llm_configs = {
|
||||
-1: {"id": -1, "billing_tier": "premium"},
|
||||
-2: {"id": -2, "billing_tier": "free"},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"app.agents.chat.runtime.llm_config.load_global_llm_config_by_id",
|
||||
lambda cid: llm_configs.get(cid),
|
||||
)
|
||||
|
||||
from app.config import config as app_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
app_config,
|
||||
"GLOBAL_IMAGE_GEN_CONFIGS",
|
||||
[
|
||||
{"id": -1, "billing_tier": "premium"},
|
||||
{"id": -2, "billing_tier": "free"},
|
||||
],
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_config,
|
||||
"GLOBAL_VISION_LLM_CONFIGS",
|
||||
"GLOBAL_MODELS",
|
||||
[
|
||||
{"id": -1, "billing_tier": "premium"},
|
||||
{"id": -2, "billing_tier": "free"},
|
||||
|
|
@ -71,7 +53,7 @@ def patched_globals(monkeypatch: pytest.MonkeyPatch):
|
|||
return None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
|
||||
@pytest.mark.parametrize("kind", ["chat", "image", "vision"])
|
||||
def test_byok_positive_id_is_allowed(kind: str, patched_globals) -> None:
|
||||
"""A positive config id is a user-owned BYOK model — always billable."""
|
||||
allowed, reason = model_policy._classify(kind, 7)
|
||||
|
|
@ -79,7 +61,7 @@ def test_byok_positive_id_is_allowed(kind: str, patched_globals) -> None:
|
|||
assert reason == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
|
||||
@pytest.mark.parametrize("kind", ["chat", "image", "vision"])
|
||||
@pytest.mark.parametrize("config_id", [0, None])
|
||||
def test_auto_mode_is_blocked(kind: str, config_id, patched_globals) -> None:
|
||||
"""Auto mode (id 0) and an unset slot (None) are blocked."""
|
||||
|
|
@ -88,7 +70,7 @@ def test_auto_mode_is_blocked(kind: str, config_id, patched_globals) -> None:
|
|||
assert "Auto mode" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
|
||||
@pytest.mark.parametrize("kind", ["chat", "image", "vision"])
|
||||
def test_premium_global_is_allowed(kind: str, patched_globals) -> None:
|
||||
"""A negative (global) id with premium billing tier is allowed."""
|
||||
allowed, reason = model_policy._classify(kind, -1)
|
||||
|
|
@ -96,7 +78,7 @@ def test_premium_global_is_allowed(kind: str, patched_globals) -> None:
|
|||
assert reason == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
|
||||
@pytest.mark.parametrize("kind", ["chat", "image", "vision"])
|
||||
def test_free_global_is_blocked(kind: str, patched_globals) -> None:
|
||||
"""A negative (global) id with a free billing tier is blocked."""
|
||||
allowed, reason = model_policy._classify(kind, -2)
|
||||
|
|
@ -104,7 +86,7 @@ def test_free_global_is_blocked(kind: str, patched_globals) -> None:
|
|||
assert "free model" in reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["llm", "image", "vision"])
|
||||
@pytest.mark.parametrize("kind", ["chat", "image", "vision"])
|
||||
def test_unknown_global_id_is_blocked(kind: str, patched_globals) -> None:
|
||||
"""A negative id that resolves to no config is treated as not premium."""
|
||||
allowed, _ = model_policy._classify(kind, -999)
|
||||
|
|
@ -125,10 +107,10 @@ def test_eligibility_reports_each_violation(patched_globals) -> None:
|
|||
|
||||
assert result["allowed"] is False
|
||||
kinds = {v["kind"] for v in result["violations"]}
|
||||
assert kinds == {"llm", "image", "vision"}
|
||||
# config_id is echoed back for the UI / settings deep-link.
|
||||
by_kind = {v["kind"]: v["config_id"] for v in result["violations"]}
|
||||
assert by_kind == {"llm": -2, "image": 0, "vision": -2}
|
||||
assert kinds == {"chat", "image", "vision"}
|
||||
# model_id is echoed back for the UI / settings deep-link.
|
||||
by_kind = {v["kind"]: v["model_id"] for v in result["violations"]}
|
||||
assert by_kind == {"chat": -2, "image": 0, "vision": -2}
|
||||
|
||||
|
||||
def test_assert_raises_with_violations(patched_globals) -> None:
|
||||
|
|
@ -138,7 +120,7 @@ def test_assert_raises_with_violations(patched_globals) -> None:
|
|||
assert_automation_models_billable(search_space)
|
||||
|
||||
assert len(exc_info.value.violations) == 1
|
||||
assert exc_info.value.violations[0]["kind"] == "llm"
|
||||
assert exc_info.value.violations[0]["kind"] == "chat"
|
||||
|
||||
|
||||
def test_assert_passes_when_all_billable(patched_globals) -> None:
|
||||
|
|
@ -153,7 +135,7 @@ def test_assert_passes_when_all_billable(patched_globals) -> None:
|
|||
def test_get_model_eligibility_all_billable(patched_globals) -> None:
|
||||
"""Premium LLM + BYOK image + premium vision (explicit ids) → allowed."""
|
||||
result = get_model_eligibility(
|
||||
agent_llm_id=-1, image_generation_config_id=5, vision_llm_config_id=-1
|
||||
chat_model_id=-1, image_gen_model_id=5, vision_model_id=-1
|
||||
)
|
||||
assert result == {"allowed": True, "violations": []}
|
||||
|
||||
|
|
@ -161,28 +143,28 @@ def test_get_model_eligibility_all_billable(patched_globals) -> None:
|
|||
def test_get_model_eligibility_reports_each_violation(patched_globals) -> None:
|
||||
"""Free LLM, Auto image, free vision (explicit ids) each produce a violation."""
|
||||
result = get_model_eligibility(
|
||||
agent_llm_id=-2, image_generation_config_id=0, vision_llm_config_id=-2
|
||||
chat_model_id=-2, image_gen_model_id=0, vision_model_id=-2
|
||||
)
|
||||
assert result["allowed"] is False
|
||||
by_kind = {v["kind"]: v["config_id"] for v in result["violations"]}
|
||||
assert by_kind == {"llm": -2, "image": 0, "vision": -2}
|
||||
by_kind = {v["kind"]: v["model_id"] for v in result["violations"]}
|
||||
assert by_kind == {"chat": -2, "image": 0, "vision": -2}
|
||||
|
||||
|
||||
def test_assert_models_billable_raises(patched_globals) -> None:
|
||||
"""``assert_models_billable`` raises when any explicit id is blocked."""
|
||||
with pytest.raises(AutomationModelPolicyError) as exc_info:
|
||||
assert_models_billable(
|
||||
agent_llm_id=0, image_generation_config_id=5, vision_llm_config_id=-1
|
||||
chat_model_id=0, image_gen_model_id=5, vision_model_id=-1
|
||||
)
|
||||
assert len(exc_info.value.violations) == 1
|
||||
assert exc_info.value.violations[0]["kind"] == "llm"
|
||||
assert exc_info.value.violations[0]["kind"] == "chat"
|
||||
|
||||
|
||||
def test_assert_models_billable_passes(patched_globals) -> None:
|
||||
"""No exception when every explicit id is premium or BYOK."""
|
||||
assert (
|
||||
assert_models_billable(
|
||||
agent_llm_id=3, image_generation_config_id=-1, vision_llm_config_id=4
|
||||
chat_model_id=3, image_gen_model_id=-1, vision_model_id=4
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
|
@ -192,5 +174,5 @@ def test_search_space_wrapper_delegates_to_core(patched_globals) -> None:
|
|||
"""The search-space wrapper produces the same result as the ID core."""
|
||||
search_space = _search_space(llm=-2, image=0, vision=-2)
|
||||
assert get_automation_model_eligibility(search_space) == get_model_eligibility(
|
||||
agent_llm_id=-2, image_generation_config_id=0, vision_llm_config_id=-2
|
||||
chat_model_id=-2, image_gen_model_id=0, vision_model_id=-2
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
"""Unit tests for ``supports_image_input`` derivation on BYOK chat config
|
||||
endpoints (``GET /new-llm-configs`` list, ``GET /new-llm-configs/{id}``).
|
||||
|
||||
There is no DB column for ``supports_image_input`` on
|
||||
``NewLLMConfig`` — the value is resolved at the API boundary by
|
||||
``derive_supports_image_input`` so the new-chat selector / streaming
|
||||
task can read the same field shape regardless of source (BYOK vs YAML
|
||||
vs OpenRouter dynamic). Default-allow on unknown so we don't lock the
|
||||
user out of their own model choice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import LiteLLMProvider
|
||||
from app.routes import new_llm_config_routes
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _byok_row(
|
||||
*,
|
||||
id_: int,
|
||||
model_name: str,
|
||||
base_model: str | None = None,
|
||||
provider: LiteLLMProvider = LiteLLMProvider.OPENAI,
|
||||
custom_provider: str | None = None,
|
||||
) -> object:
|
||||
"""Mimic the SQLAlchemy row's attribute surface; ``model_validate``
|
||||
walks ``from_attributes=True`` so a ``SimpleNamespace`` is enough.
|
||||
|
||||
``provider`` is a real ``LiteLLMProvider`` enum value so Pydantic's
|
||||
enum validator accepts it — same as the ORM row would carry."""
|
||||
return SimpleNamespace(
|
||||
id=id_,
|
||||
name=f"BYOK-{id_}",
|
||||
description=None,
|
||||
provider=provider,
|
||||
custom_provider=custom_provider,
|
||||
model_name=model_name,
|
||||
api_key="sk-byok",
|
||||
api_base=None,
|
||||
litellm_params={"base_model": base_model} if base_model else None,
|
||||
system_instructions="",
|
||||
use_default_system_instructions=True,
|
||||
citations_enabled=True,
|
||||
created_at=datetime.now(tz=UTC),
|
||||
search_space_id=42,
|
||||
user_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_byok_known_vision_model_resolves_true():
|
||||
"""The catalog resolver consults LiteLLM's map for ``gpt-4o`` ->
|
||||
True. The serialized row carries that value through to the
|
||||
``NewLLMConfigRead`` schema."""
|
||||
row = _byok_row(id_=1, model_name="gpt-4o")
|
||||
serialized = new_llm_config_routes._serialize_byok_config(row)
|
||||
|
||||
assert serialized.supports_image_input is True
|
||||
assert serialized.id == 1
|
||||
assert serialized.model_name == "gpt-4o"
|
||||
|
||||
|
||||
def test_serialize_byok_unknown_model_default_allows():
|
||||
"""Unknown / unmapped: default-allow. The streaming-task safety net
|
||||
is the actual block, and it requires LiteLLM to *explicitly* say
|
||||
text-only — so a brand new BYOK model should not be pre-judged."""
|
||||
row = _byok_row(
|
||||
id_=2,
|
||||
model_name="brand-new-model-x9-unmapped",
|
||||
provider=LiteLLMProvider.CUSTOM,
|
||||
custom_provider="brand_new_proxy",
|
||||
)
|
||||
serialized = new_llm_config_routes._serialize_byok_config(row)
|
||||
|
||||
assert serialized.supports_image_input is True
|
||||
|
||||
|
||||
def test_serialize_byok_uses_base_model_when_present():
|
||||
"""Azure-style: ``model_name`` is the deployment id, ``base_model``
|
||||
inside ``litellm_params`` is the canonical sku LiteLLM knows. The
|
||||
helper must consult ``base_model`` first or unrecognised deployment
|
||||
ids would shadow the real capability."""
|
||||
row = _byok_row(
|
||||
id_=3,
|
||||
model_name="my-azure-deployment-id-no-litellm-knows-this",
|
||||
base_model="gpt-4o",
|
||||
provider=LiteLLMProvider.AZURE_OPENAI,
|
||||
)
|
||||
serialized = new_llm_config_routes._serialize_byok_config(row)
|
||||
|
||||
assert serialized.supports_image_input is True
|
||||
|
||||
|
||||
def test_serialize_byok_returns_pydantic_read_model():
|
||||
"""The route now returns ``NewLLMConfigRead`` (not the raw ORM) so
|
||||
the schema additions are guaranteed to be present in the API
|
||||
surface. This guards against a future regression where someone
|
||||
deletes the augmentation step and falls back to ORM passthrough."""
|
||||
from app.schemas import NewLLMConfigRead
|
||||
|
||||
row = _byok_row(id_=4, model_name="gpt-4o")
|
||||
serialized = new_llm_config_routes._serialize_byok_config(row)
|
||||
assert isinstance(serialized, NewLLMConfigRead)
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
"""Unit tests for ``is_premium`` derivation on the global image-gen and
|
||||
vision-LLM list endpoints.
|
||||
|
||||
Chat globals (``GET /global-llm-configs``) already emit
|
||||
``is_premium = (billing_tier == "premium")``. Image and vision did not,
|
||||
which made the new-chat ``model-selector`` render the Free/Premium badge
|
||||
on the Chat tab but skip it on the Image and Vision tabs (the selector
|
||||
keys its badge logic off ``is_premium``). These tests pin parity:
|
||||
|
||||
* YAML free entry → ``is_premium=False``
|
||||
* YAML premium entry → ``is_premium=True``
|
||||
* OpenRouter dynamic premium entry → ``is_premium=True``
|
||||
* Auto stub (always emitted when at least one config is present)
|
||||
→ ``is_premium=False``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
_IMAGE_FIXTURE: list[dict] = [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "DALL-E 3",
|
||||
"litellm_provider": "openai",
|
||||
"model_name": "dall-e-3",
|
||||
"api_key": "sk-test",
|
||||
"billing_tier": "free",
|
||||
},
|
||||
{
|
||||
"id": -2,
|
||||
"name": "GPT-Image 1 (premium)",
|
||||
"litellm_provider": "openai",
|
||||
"model_name": "gpt-image-1",
|
||||
"api_key": "sk-test",
|
||||
"billing_tier": "premium",
|
||||
},
|
||||
{
|
||||
"id": -20_001,
|
||||
"name": "google/gemini-2.5-flash-image (OpenRouter)",
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "google/gemini-2.5-flash-image",
|
||||
"api_key": "sk-or-test",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"billing_tier": "premium",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
_VISION_FIXTURE: list[dict] = [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "GPT-4o Vision",
|
||||
"litellm_provider": "openai",
|
||||
"model_name": "gpt-4o",
|
||||
"api_key": "sk-test",
|
||||
"billing_tier": "free",
|
||||
},
|
||||
{
|
||||
"id": -2,
|
||||
"name": "Claude 3.5 Sonnet (premium)",
|
||||
"litellm_provider": "anthropic",
|
||||
"model_name": "claude-3-5-sonnet",
|
||||
"api_key": "sk-ant-test",
|
||||
"billing_tier": "premium",
|
||||
},
|
||||
{
|
||||
"id": -30_001,
|
||||
"name": "openai/gpt-4o (OpenRouter)",
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "openai/gpt-4o",
|
||||
"api_key": "sk-or-test",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"billing_tier": "premium",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image generation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_image_gen_configs_emit_is_premium(monkeypatch):
|
||||
"""Each emitted config must carry ``is_premium`` derived server-side
|
||||
from ``billing_tier``. The Auto stub is always free.
|
||||
"""
|
||||
from app.config import config
|
||||
from app.routes import image_generation_routes
|
||||
|
||||
monkeypatch.setattr(
|
||||
config, "GLOBAL_IMAGE_GEN_CONFIGS", _IMAGE_FIXTURE, raising=False
|
||||
)
|
||||
|
||||
payload = await image_generation_routes.get_global_image_gen_configs(user=None)
|
||||
|
||||
by_id = {c["id"]: c for c in payload}
|
||||
|
||||
# Auto stub is always emitted when at least one global config exists,
|
||||
# and it must always declare itself free (Auto-mode billing-tier
|
||||
# surfacing is a separate follow-up).
|
||||
assert 0 in by_id, "Auto stub should be emitted when at least one config exists"
|
||||
assert by_id[0]["is_premium"] is False
|
||||
assert by_id[0]["billing_tier"] == "free"
|
||||
|
||||
# YAML free entry — ``is_premium=False``
|
||||
assert by_id[-1]["is_premium"] is False
|
||||
assert by_id[-1]["billing_tier"] == "free"
|
||||
|
||||
# YAML premium entry — ``is_premium=True``
|
||||
assert by_id[-2]["is_premium"] is True
|
||||
assert by_id[-2]["billing_tier"] == "premium"
|
||||
|
||||
# OpenRouter dynamic premium entry — same field, same derivation
|
||||
assert by_id[-20_001]["is_premium"] is True
|
||||
assert by_id[-20_001]["billing_tier"] == "premium"
|
||||
|
||||
# Every emitted dict (including Auto) must have the field — never missing.
|
||||
for cfg in payload:
|
||||
assert "is_premium" in cfg, f"is_premium missing from {cfg.get('id')}"
|
||||
assert isinstance(cfg["is_premium"], bool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_image_gen_configs_no_globals_no_auto_stub(monkeypatch):
|
||||
"""When there are no global configs at all, the endpoint emits an
|
||||
empty list (no Auto stub) — Auto mode would have nothing to route to.
|
||||
"""
|
||||
from app.config import config
|
||||
from app.routes import image_generation_routes
|
||||
|
||||
monkeypatch.setattr(config, "GLOBAL_IMAGE_GEN_CONFIGS", [], raising=False)
|
||||
payload = await image_generation_routes.get_global_image_gen_configs(user=None)
|
||||
assert payload == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Vision LLM
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_vision_llm_configs_emit_is_premium(monkeypatch):
|
||||
from app.config import config
|
||||
from app.routes import vision_llm_routes
|
||||
|
||||
monkeypatch.setattr(
|
||||
config, "GLOBAL_VISION_LLM_CONFIGS", _VISION_FIXTURE, raising=False
|
||||
)
|
||||
|
||||
payload = await vision_llm_routes.get_global_vision_llm_configs(user=None)
|
||||
|
||||
by_id = {c["id"]: c for c in payload}
|
||||
|
||||
assert 0 in by_id, "Auto stub should be emitted when at least one config exists"
|
||||
assert by_id[0]["is_premium"] is False
|
||||
assert by_id[0]["billing_tier"] == "free"
|
||||
|
||||
assert by_id[-1]["is_premium"] is False
|
||||
assert by_id[-1]["billing_tier"] == "free"
|
||||
|
||||
assert by_id[-2]["is_premium"] is True
|
||||
assert by_id[-2]["billing_tier"] == "premium"
|
||||
|
||||
assert by_id[-30_001]["is_premium"] is True
|
||||
assert by_id[-30_001]["billing_tier"] == "premium"
|
||||
|
||||
for cfg in payload:
|
||||
assert "is_premium" in cfg, f"is_premium missing from {cfg.get('id')}"
|
||||
assert isinstance(cfg["is_premium"], bool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_vision_llm_configs_no_globals_no_auto_stub(monkeypatch):
|
||||
from app.config import config
|
||||
from app.routes import vision_llm_routes
|
||||
|
||||
monkeypatch.setattr(config, "GLOBAL_VISION_LLM_CONFIGS", [], raising=False)
|
||||
payload = await vision_llm_routes.get_global_vision_llm_configs(user=None)
|
||||
assert payload == []
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
"""Unit tests for ``supports_image_input`` derivation on the chat global
|
||||
config endpoint (``GET /global-new-llm-configs``).
|
||||
|
||||
Resolution order (matches ``new_llm_config_routes.get_global_new_llm_configs``):
|
||||
|
||||
1. Explicit ``supports_image_input`` on the cfg dict (set by the YAML
|
||||
loader for operator overrides, or by the OpenRouter integration from
|
||||
``architecture.input_modalities``) — wins.
|
||||
2. ``derive_supports_image_input`` helper — default-allow on unknown
|
||||
models, only False when LiteLLM / OR modalities are definitive.
|
||||
|
||||
The flag is purely informational at the API boundary. The streaming
|
||||
task safety net (``is_known_text_only_chat_model``) is the actual block,
|
||||
and it requires LiteLLM to *explicitly* mark the model as text-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
_FIXTURE: list[dict] = [
|
||||
{
|
||||
"id": -1,
|
||||
"name": "GPT-4o (explicit true)",
|
||||
"description": "vision-capable, explicit YAML override",
|
||||
"litellm_provider": "openai",
|
||||
"model_name": "gpt-4o",
|
||||
"api_key": "sk-test",
|
||||
"billing_tier": "free",
|
||||
"supports_image_input": True,
|
||||
},
|
||||
{
|
||||
"id": -2,
|
||||
"name": "DeepSeek V3 (explicit false)",
|
||||
"description": "OpenRouter dynamic — modality-derived false",
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "deepseek/deepseek-v3.2-exp",
|
||||
"api_key": "sk-or-test",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"billing_tier": "free",
|
||||
"supports_image_input": False,
|
||||
},
|
||||
{
|
||||
"id": -10_010,
|
||||
"name": "Unannotated GPT-4o",
|
||||
"description": "no flag set — resolver should derive True via LiteLLM",
|
||||
"litellm_provider": "openai",
|
||||
"model_name": "gpt-4o",
|
||||
"api_key": "sk-test",
|
||||
"billing_tier": "free",
|
||||
# supports_image_input intentionally absent
|
||||
},
|
||||
{
|
||||
"id": -10_011,
|
||||
"name": "Unannotated unknown model",
|
||||
"description": "unmapped — default-allow True",
|
||||
"litellm_provider": "custom",
|
||||
"custom_provider": "brand_new_proxy",
|
||||
"model_name": "brand-new-model-x9",
|
||||
"api_key": "sk-test",
|
||||
"billing_tier": "free",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_new_llm_configs_emit_supports_image_input(monkeypatch):
|
||||
"""Each emitted chat config carries ``supports_image_input`` as a
|
||||
bool. Explicit values win; unannotated entries are resolved via the
|
||||
helper (default-allow True)."""
|
||||
from app.config import config
|
||||
from app.routes import new_llm_config_routes
|
||||
|
||||
monkeypatch.setattr(config, "GLOBAL_LLM_CONFIGS", _FIXTURE, raising=False)
|
||||
|
||||
payload = await new_llm_config_routes.get_global_new_llm_configs(user=None)
|
||||
by_id = {c["id"]: c for c in payload}
|
||||
|
||||
# Auto stub: optimistic True so the user can keep Auto selected with
|
||||
# vision-capable deployments somewhere in the pool.
|
||||
assert 0 in by_id, "Auto stub should be emitted when configs exist"
|
||||
assert by_id[0]["supports_image_input"] is True
|
||||
assert by_id[0]["is_auto_mode"] is True
|
||||
|
||||
# Explicit True is preserved.
|
||||
assert by_id[-1]["supports_image_input"] is True
|
||||
|
||||
# Explicit False is preserved (the exact failure mode the safety net
|
||||
# guards against — DeepSeek V3 over OpenRouter would 404 with "No
|
||||
# endpoints found that support image input").
|
||||
assert by_id[-2]["supports_image_input"] is False
|
||||
|
||||
# Unannotated GPT-4o: resolver consults LiteLLM, which says vision.
|
||||
assert by_id[-10_010]["supports_image_input"] is True
|
||||
|
||||
# Unknown / unmapped model: default-allow rather than pre-judge.
|
||||
assert by_id[-10_011]["supports_image_input"] is True
|
||||
|
||||
for cfg in payload:
|
||||
assert "supports_image_input" in cfg, (
|
||||
f"supports_image_input missing from {cfg.get('id')}"
|
||||
)
|
||||
assert isinstance(cfg["supports_image_input"], bool)
|
||||
|
|
@ -27,9 +27,18 @@ async def test_resolve_billing_for_auto_mode(monkeypatch):
|
|||
from app.routes import image_generation_routes
|
||||
from app.services.billable_calls import DEFAULT_IMAGE_RESERVE_MICROS
|
||||
|
||||
search_space = SimpleNamespace(image_generation_config_id=None)
|
||||
async def _no_auto_candidates(*_args, **_kwargs):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
image_generation_routes,
|
||||
"auto_model_candidates",
|
||||
_no_auto_candidates,
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
|
||||
tier, model, reserve = await image_generation_routes._resolve_billing_for_image_gen(
|
||||
session=None, # Not consumed on this code path.
|
||||
session=None,
|
||||
config_id=0, # IMAGE_GEN_AUTO_MODE_ID
|
||||
search_space=search_space,
|
||||
)
|
||||
|
|
@ -45,26 +54,42 @@ async def test_resolve_billing_for_premium_global_config(monkeypatch):
|
|||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"GLOBAL_IMAGE_GEN_CONFIGS",
|
||||
"GLOBAL_MODELS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
"litellm_provider": "openai",
|
||||
"model_name": "gpt-image-1",
|
||||
"connection_id": -101,
|
||||
"model_id": "gpt-image-1",
|
||||
"billing_tier": "premium",
|
||||
"quota_reserve_micros": 75_000,
|
||||
"catalog": {"quota_reserve_micros": 75_000},
|
||||
},
|
||||
{
|
||||
"id": -2,
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "google/gemini-2.5-flash-image",
|
||||
"connection_id": -102,
|
||||
"model_id": "google/gemini-2.5-flash-image",
|
||||
"billing_tier": "free",
|
||||
"catalog": {},
|
||||
},
|
||||
],
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"GLOBAL_CONNECTIONS",
|
||||
[
|
||||
{"id": -101, "provider": "openai", "api_key": "sk-test", "base_url": None, "extra": {}},
|
||||
{
|
||||
"id": -102,
|
||||
"provider": "openrouter",
|
||||
"api_key": "sk-or-test",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"extra": {},
|
||||
},
|
||||
],
|
||||
raising=False,
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(image_generation_config_id=None)
|
||||
search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
|
||||
|
||||
# Premium with override.
|
||||
tier, model, reserve = await image_generation_routes._resolve_billing_for_image_gen(
|
||||
|
|
@ -94,7 +119,7 @@ async def test_resolve_billing_for_user_owned_byok_is_free():
|
|||
from app.routes import image_generation_routes
|
||||
from app.services.billable_calls import DEFAULT_IMAGE_RESERVE_MICROS
|
||||
|
||||
search_space = SimpleNamespace(image_generation_config_id=None)
|
||||
search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=None)
|
||||
tier, model, reserve = await image_generation_routes._resolve_billing_for_image_gen(
|
||||
session=None, config_id=42, search_space=search_space
|
||||
)
|
||||
|
|
@ -105,7 +130,7 @@ async def test_resolve_billing_for_user_owned_byok_is_free():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_billing_falls_back_to_search_space_default(monkeypatch):
|
||||
"""When the request omits ``image_generation_config_id``, the helper
|
||||
"""When the request omits ``image_gen_model_id``, the helper
|
||||
must consult the search space's default — so a search space pinned
|
||||
to a premium global config still gates new requests by quota.
|
||||
"""
|
||||
|
|
@ -114,19 +139,26 @@ async def test_resolve_billing_falls_back_to_search_space_default(monkeypatch):
|
|||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"GLOBAL_IMAGE_GEN_CONFIGS",
|
||||
"GLOBAL_MODELS",
|
||||
[
|
||||
{
|
||||
"id": -7,
|
||||
"litellm_provider": "openai",
|
||||
"model_name": "gpt-image-1",
|
||||
"connection_id": -101,
|
||||
"model_id": "gpt-image-1",
|
||||
"billing_tier": "premium",
|
||||
"catalog": {},
|
||||
}
|
||||
],
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"GLOBAL_CONNECTIONS",
|
||||
[{"id": -101, "provider": "openai", "api_key": "sk-test", "base_url": None, "extra": {}}],
|
||||
raising=False,
|
||||
)
|
||||
|
||||
search_space = SimpleNamespace(image_generation_config_id=-7)
|
||||
search_space = SimpleNamespace(id=1, user_id=None, image_gen_model_id=-7)
|
||||
(
|
||||
tier,
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,4 @@
|
|||
"""Unit tests for ``_resolve_agent_billing_for_search_space``.
|
||||
|
||||
Validates the resolver used by Celery podcast/video tasks to compute
|
||||
``(owner_user_id, billing_tier, base_model)`` from a search space and its
|
||||
agent LLM config. The resolver mirrors chat's billing-resolution pattern at
|
||||
``stream_new_chat.py:2294-2351`` and is the single integration point that
|
||||
prevents Auto-mode podcast/video from leaking premium credit.
|
||||
|
||||
Coverage:
|
||||
|
||||
* Auto mode + ``thread_id`` set, pin resolves to a negative-id premium
|
||||
global → returns ``("premium", <base_model>)``.
|
||||
* Auto mode + ``thread_id`` set, pin resolves to a negative-id free
|
||||
global → returns ``("free", <base_model>)``.
|
||||
* Auto mode + ``thread_id`` set, pin resolves to a positive-id BYOK config
|
||||
→ always ``"free"``.
|
||||
* Auto mode + ``thread_id=None`` → fallback to ``("free", "auto")`` without
|
||||
hitting the pin service.
|
||||
* Negative id (no Auto) → uses ``get_global_llm_config``'s
|
||||
``billing_tier``.
|
||||
* Positive id (user BYOK) → always ``"free"``.
|
||||
* Search space not found → raises ``ValueError``.
|
||||
* ``agent_llm_id`` is None → raises ``ValueError``.
|
||||
"""
|
||||
"""Unit tests for ``_resolve_agent_billing_for_search_space``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -34,11 +11,6 @@ import pytest
|
|||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeExecResult:
|
||||
def __init__(self, obj):
|
||||
self._obj = obj
|
||||
|
|
@ -51,14 +23,6 @@ class _FakeExecResult:
|
|||
|
||||
|
||||
class _FakeSession:
|
||||
"""Tiny AsyncSession stub.
|
||||
|
||||
``responses`` is a list of objects to return from successive
|
||||
``execute()`` calls (in order). The resolver makes at most two
|
||||
``execute()`` calls (search-space lookup, then optionally NewLLMConfig
|
||||
lookup), so two queued responses cover the matrix.
|
||||
"""
|
||||
|
||||
def __init__(self, responses: list):
|
||||
self._responses = list(responses)
|
||||
|
||||
|
|
@ -67,9 +31,6 @@ class _FakeSession:
|
|||
return _FakeExecResult(None)
|
||||
return _FakeExecResult(self._responses.pop(0))
|
||||
|
||||
async def commit(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakePinResolution:
|
||||
|
|
@ -78,53 +39,33 @@ class _FakePinResolution:
|
|||
from_existing_pin: bool = False
|
||||
|
||||
|
||||
def _make_search_space(*, agent_llm_id: int | None, user_id: UUID) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=42,
|
||||
agent_llm_id=agent_llm_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
def _make_search_space(*, chat_model_id: int | None, user_id: UUID) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=42, chat_model_id=chat_model_id, user_id=user_id)
|
||||
|
||||
|
||||
def _make_byok_config(
|
||||
*, id_: int, base_model: str | None = None, model_name: str = "gpt-byok"
|
||||
def _make_byok_model(
|
||||
*, id_: int, base_model: str | None = None, model_id: str = "gpt-byok"
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=id_,
|
||||
model_name=model_name,
|
||||
litellm_params={"base_model": base_model} if base_model else {},
|
||||
model_id=model_id,
|
||||
catalog={"base_model": base_model} if base_model else {},
|
||||
connection=SimpleNamespace(enabled=True, search_space_id=42, user_id=None),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_mode_with_thread_id_resolves_to_premium_global(monkeypatch):
|
||||
"""Auto + thread → pin service resolves to negative-id premium config →
|
||||
resolver returns ``("premium", <base_model>)``."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=0, user_id=user_id)])
|
||||
session = _FakeSession([_make_search_space(chat_model_id=0, user_id=user_id)])
|
||||
|
||||
# Mock the pin service to return a concrete premium config id.
|
||||
async def _fake_resolve_pin(
|
||||
sess,
|
||||
*,
|
||||
thread_id,
|
||||
search_space_id,
|
||||
user_id,
|
||||
selected_llm_config_id,
|
||||
force_repin_free=False,
|
||||
):
|
||||
assert selected_llm_config_id == 0
|
||||
assert thread_id == 99
|
||||
async def _fake_resolve_pin(*_args, **kwargs):
|
||||
assert kwargs["selected_llm_config_id"] == 0
|
||||
assert kwargs["thread_id"] == 99
|
||||
return _FakePinResolution(resolved_llm_config_id=-1, resolved_tier="premium")
|
||||
|
||||
# Mock global config lookup to return a premium entry.
|
||||
def _fake_get_global(cfg_id):
|
||||
if cfg_id == -1:
|
||||
return {
|
||||
|
|
@ -135,8 +76,6 @@ async def test_auto_mode_with_thread_id_resolves_to_premium_global(monkeypatch):
|
|||
}
|
||||
return None
|
||||
|
||||
# Lazy imports inside the resolver — patch the *target* modules so the
|
||||
# imported names resolve to our fakes.
|
||||
import app.services.auto_model_pin_service as pin_module
|
||||
import app.services.llm_service as llm_module
|
||||
|
||||
|
|
@ -154,77 +93,18 @@ async def test_auto_mode_with_thread_id_resolves_to_premium_global(monkeypatch):
|
|||
assert base_model == "gpt-5.4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_mode_with_thread_id_resolves_to_free_global(monkeypatch):
|
||||
"""Auto + thread → pin returns negative-id free config → resolver
|
||||
returns ``("free", <base_model>)``. Same path the pin service takes for
|
||||
out-of-credit users (graceful degradation)."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=0, user_id=user_id)])
|
||||
|
||||
async def _fake_resolve_pin(
|
||||
sess,
|
||||
*,
|
||||
thread_id,
|
||||
search_space_id,
|
||||
user_id,
|
||||
selected_llm_config_id,
|
||||
force_repin_free=False,
|
||||
):
|
||||
return _FakePinResolution(resolved_llm_config_id=-3, resolved_tier="free")
|
||||
|
||||
def _fake_get_global(cfg_id):
|
||||
if cfg_id == -3:
|
||||
return {
|
||||
"id": -3,
|
||||
"model_name": "openrouter/free-model",
|
||||
"billing_tier": "free",
|
||||
"litellm_params": {"base_model": "openrouter/free-model"},
|
||||
}
|
||||
return None
|
||||
|
||||
import app.services.auto_model_pin_service as pin_module
|
||||
import app.services.llm_service as llm_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
pin_module, "resolve_or_get_pinned_llm_config_id", _fake_resolve_pin
|
||||
)
|
||||
monkeypatch.setattr(llm_module, "get_global_llm_config", _fake_get_global)
|
||||
|
||||
owner, tier, base_model = await _resolve_agent_billing_for_search_space(
|
||||
session, search_space_id=42, thread_id=99
|
||||
)
|
||||
|
||||
assert owner == user_id
|
||||
assert tier == "free"
|
||||
assert base_model == "openrouter/free-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_mode_with_thread_id_resolves_to_byok_is_free(monkeypatch):
|
||||
"""Auto + thread → pin returns positive-id BYOK config → resolver
|
||||
returns ``("free", ...)`` (BYOK is always free per
|
||||
``AgentConfig.from_new_llm_config``)."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
search_space = _make_search_space(agent_llm_id=0, user_id=user_id)
|
||||
byok_cfg = _make_byok_config(
|
||||
id_=17, base_model="anthropic/claude-3-haiku", model_name="my-claude"
|
||||
search_space = _make_search_space(chat_model_id=0, user_id=user_id)
|
||||
byok_model = _make_byok_model(
|
||||
id_=17, base_model="anthropic/claude-3-haiku", model_id="my-claude"
|
||||
)
|
||||
session = _FakeSession([search_space, byok_cfg])
|
||||
session = _FakeSession([search_space, byok_model])
|
||||
|
||||
async def _fake_resolve_pin(
|
||||
sess,
|
||||
*,
|
||||
thread_id,
|
||||
search_space_id,
|
||||
user_id,
|
||||
selected_llm_config_id,
|
||||
force_repin_free=False,
|
||||
):
|
||||
async def _fake_resolve_pin(*_args, **_kwargs):
|
||||
return _FakePinResolution(resolved_llm_config_id=17, resolved_tier="free")
|
||||
|
||||
import app.services.auto_model_pin_service as pin_module
|
||||
|
|
@ -244,13 +124,10 @@ async def test_auto_mode_with_thread_id_resolves_to_byok_is_free(monkeypatch):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_mode_without_thread_id_falls_back_to_free():
|
||||
"""Auto + ``thread_id=None`` → ``("free", "auto")`` without invoking
|
||||
the pin service. Forward-compat fallback for any future direct-API
|
||||
entrypoint that doesn't have a chat thread."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=0, user_id=user_id)])
|
||||
session = _FakeSession([_make_search_space(chat_model_id=0, user_id=user_id)])
|
||||
|
||||
owner, tier, base_model = await _resolve_agent_billing_for_search_space(
|
||||
session, search_space_id=42, thread_id=None
|
||||
|
|
@ -263,13 +140,10 @@ async def test_auto_mode_without_thread_id_falls_back_to_free():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_mode_pin_failure_falls_back_to_free(monkeypatch):
|
||||
"""If the pin service raises ``ValueError`` (thread missing /
|
||||
mismatched search space), the resolver should log and return free
|
||||
rather than killing the whole task."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=0, user_id=user_id)])
|
||||
session = _FakeSession([_make_search_space(chat_model_id=0, user_id=user_id)])
|
||||
|
||||
async def _fake_resolve_pin(*args, **kwargs):
|
||||
raise ValueError("thread missing")
|
||||
|
|
@ -291,12 +165,10 @@ async def test_auto_mode_pin_failure_falls_back_to_free(monkeypatch):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_id_premium_global_returns_premium(monkeypatch):
|
||||
"""Explicit negative agent_llm_id → ``get_global_llm_config`` →
|
||||
return its ``billing_tier``."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=-1, user_id=user_id)])
|
||||
session = _FakeSession([_make_search_space(chat_model_id=-1, user_id=user_id)])
|
||||
|
||||
def _fake_get_global(cfg_id):
|
||||
return {
|
||||
|
|
@ -319,50 +191,15 @@ async def test_negative_id_premium_global_returns_premium(monkeypatch):
|
|||
assert base_model == "gpt-5.4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_id_free_global_returns_free(monkeypatch):
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=-2, user_id=user_id)])
|
||||
|
||||
def _fake_get_global(cfg_id):
|
||||
return {
|
||||
"id": cfg_id,
|
||||
"model_name": "openrouter/some-free",
|
||||
"billing_tier": "free",
|
||||
"litellm_params": {"base_model": "openrouter/some-free"},
|
||||
}
|
||||
|
||||
import app.services.llm_service as llm_module
|
||||
|
||||
monkeypatch.setattr(llm_module, "get_global_llm_config", _fake_get_global)
|
||||
|
||||
owner, tier, base_model = await _resolve_agent_billing_for_search_space(
|
||||
session, search_space_id=42, thread_id=None
|
||||
)
|
||||
|
||||
assert owner == user_id
|
||||
assert tier == "free"
|
||||
assert base_model == "openrouter/some-free"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_id_missing_base_model_falls_back_to_model_name(monkeypatch):
|
||||
"""When the global config has no ``litellm_params.base_model``, the
|
||||
resolver falls back to ``model_name`` — matching chat's behavior."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=-5, user_id=user_id)])
|
||||
session = _FakeSession([_make_search_space(chat_model_id=-5, user_id=user_id)])
|
||||
|
||||
def _fake_get_global(cfg_id):
|
||||
return {
|
||||
"id": cfg_id,
|
||||
"model_name": "fallback-model",
|
||||
"billing_tier": "premium",
|
||||
# No litellm_params.
|
||||
}
|
||||
return {"id": cfg_id, "model_name": "fallback-model", "billing_tier": "premium"}
|
||||
|
||||
import app.services.llm_service as llm_module
|
||||
|
||||
|
|
@ -378,14 +215,12 @@ async def test_negative_id_missing_base_model_falls_back_to_model_name(monkeypat
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_id_byok_is_always_free():
|
||||
"""Positive agent_llm_id → user-owned BYOK NewLLMConfig → always free,
|
||||
regardless of underlying provider tier."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
search_space = _make_search_space(agent_llm_id=23, user_id=user_id)
|
||||
byok_cfg = _make_byok_config(id_=23, base_model="anthropic/claude-3.5-sonnet")
|
||||
session = _FakeSession([search_space, byok_cfg])
|
||||
search_space = _make_search_space(chat_model_id=23, user_id=user_id)
|
||||
byok_model = _make_byok_model(id_=23, base_model="anthropic/claude-3.5-sonnet")
|
||||
session = _FakeSession([search_space, byok_model])
|
||||
|
||||
owner, tier, base_model = await _resolve_agent_billing_for_search_space(
|
||||
session, search_space_id=42
|
||||
|
|
@ -398,13 +233,10 @@ async def test_positive_id_byok_is_always_free():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_id_byok_missing_returns_free_with_empty_base_model():
|
||||
"""If the BYOK config row is missing/deleted but the search space still
|
||||
points at it, the resolver still returns free (no debit) with an empty
|
||||
base_model — billable_call's premium path is skipped, no harm done."""
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=99, user_id=user_id)])
|
||||
session = _FakeSession([_make_search_space(chat_model_id=99, user_id=user_id)])
|
||||
|
||||
owner, tier, base_model = await _resolve_agent_billing_for_search_space(
|
||||
session, search_space_id=42
|
||||
|
|
@ -419,18 +251,18 @@ async def test_positive_id_byok_missing_returns_free_with_empty_base_model():
|
|||
async def test_search_space_not_found_raises_value_error():
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
session = _FakeSession([None])
|
||||
|
||||
with pytest.raises(ValueError, match="Search space"):
|
||||
await _resolve_agent_billing_for_search_space(session, search_space_id=999)
|
||||
await _resolve_agent_billing_for_search_space(
|
||||
_FakeSession([None]), search_space_id=999
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_llm_id_none_raises_value_error():
|
||||
async def test_chat_model_id_none_raises_value_error():
|
||||
from app.services.billable_calls import _resolve_agent_billing_for_search_space
|
||||
|
||||
user_id = uuid4()
|
||||
session = _FakeSession([_make_search_space(agent_llm_id=None, user_id=user_id)])
|
||||
session = _FakeSession([_make_search_space(chat_model_id=None, user_id=user_id)])
|
||||
|
||||
with pytest.raises(ValueError, match="agent_llm_id"):
|
||||
with pytest.raises(ValueError, match="chat_model_id"):
|
||||
await _resolve_agent_billing_for_search_space(session, search_space_id=42)
|
||||
|
|
|
|||
|
|
@ -32,8 +32,9 @@ class _FakeQuotaResult:
|
|||
|
||||
|
||||
class _FakeExecResult:
|
||||
def __init__(self, thread):
|
||||
def __init__(self, *, thread=None, scalars=None):
|
||||
self._thread = thread
|
||||
self._scalars = scalars or []
|
||||
|
||||
def unique(self):
|
||||
return self
|
||||
|
|
@ -41,19 +42,69 @@ class _FakeExecResult:
|
|||
def scalar_one_or_none(self):
|
||||
return self._thread
|
||||
|
||||
def scalars(self):
|
||||
return SimpleNamespace(all=lambda: self._scalars)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, thread):
|
||||
def __init__(self, thread, *, models=None):
|
||||
self.thread = thread
|
||||
self.models = models or []
|
||||
self.commit_count = 0
|
||||
self.execute_count = 0
|
||||
|
||||
async def execute(self, _stmt):
|
||||
return _FakeExecResult(self.thread)
|
||||
self.execute_count += 1
|
||||
if self.execute_count == 1:
|
||||
return _FakeExecResult(thread=self.thread)
|
||||
return _FakeExecResult(scalars=self.models)
|
||||
|
||||
async def commit(self):
|
||||
self.commit_count += 1
|
||||
|
||||
|
||||
def _set_global_llm_configs(monkeypatch, config, configs: list[dict]):
|
||||
"""Patch the new global model catalog shape from compact legacy cfg fixtures."""
|
||||
connections = []
|
||||
models = []
|
||||
for cfg in configs:
|
||||
config_id = int(cfg["id"])
|
||||
connection_id = config_id - 100_000
|
||||
provider = cfg.get("provider") or cfg.get("litellm_provider")
|
||||
model_name = cfg["model_name"]
|
||||
connections.append(
|
||||
{
|
||||
"id": connection_id,
|
||||
"provider": provider,
|
||||
"scope": "GLOBAL",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
models.append(
|
||||
{
|
||||
"id": config_id,
|
||||
"connection_id": connection_id,
|
||||
"model_id": model_name,
|
||||
"display_name": cfg.get("name") or model_name,
|
||||
"supports_chat": cfg.get("supports_chat", True),
|
||||
"supports_image_input": cfg.get("supports_image_input", True),
|
||||
"supports_tools": cfg.get("supports_tools", True),
|
||||
"supports_image_generation": cfg.get("supports_image_generation", False),
|
||||
"capabilities_override": cfg.get("capabilities_override") or {},
|
||||
"billing_tier": cfg.get("billing_tier", "free"),
|
||||
"catalog": {
|
||||
"auto_pin_tier": cfg.get("auto_pin_tier"),
|
||||
"quality_score": cfg.get("quality_score")
|
||||
or cfg.get("quality_score_static"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(config, "GLOBAL_LLM_CONFIGS", configs)
|
||||
monkeypatch.setattr(config, "GLOBAL_CONNECTIONS", connections)
|
||||
monkeypatch.setattr(config, "GLOBAL_MODELS", models)
|
||||
|
||||
|
||||
def _thread(
|
||||
*,
|
||||
search_space_id: int = 10,
|
||||
|
|
@ -71,9 +122,9 @@ async def test_auto_first_turn_pins_one_model(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{"id": -2, "litellm_provider": "openai", "model_name": "gpt-free", "api_key": "k1"},
|
||||
{
|
||||
|
|
@ -111,9 +162,9 @@ async def test_premium_eligible_auto_prefers_premium_over_free(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -2,
|
||||
|
|
@ -158,9 +209,9 @@ async def test_premium_eligible_auto_prefers_azure_gpt_5_4(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -216,9 +267,9 @@ async def test_next_turn_reuses_existing_pin(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -257,9 +308,9 @@ async def test_premium_eligible_auto_can_pin_premium(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -295,9 +346,9 @@ async def test_premium_ineligible_auto_pins_free_only(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -2,
|
||||
|
|
@ -340,9 +391,9 @@ async def test_pinned_premium_stays_premium_after_quota_exhaustion(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -2,
|
||||
|
|
@ -385,9 +436,9 @@ async def test_force_repin_free_switches_auto_premium_pin_to_free(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -2,
|
||||
|
|
@ -433,9 +484,9 @@ async def test_explicit_user_model_change_clears_pin(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-2))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{"id": -2, "litellm_provider": "openai", "model_name": "gpt-free", "api_key": "k1"},
|
||||
],
|
||||
|
|
@ -458,9 +509,9 @@ async def test_invalid_pinned_config_repairs_with_new_pin(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-999))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{"id": -2, "litellm_provider": "openai", "model_name": "gpt-free", "api_key": "k1"},
|
||||
],
|
||||
|
|
@ -487,7 +538,7 @@ async def test_invalid_pinned_config_repairs_with_new_pin(monkeypatch):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quality-aware pin selection (Auto Fastest upgrade)
|
||||
# Quality-aware pin selection (Auto upgrade)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -498,9 +549,9 @@ async def test_health_gated_config_is_excluded_from_selection(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -550,9 +601,9 @@ async def test_tier_a_locks_first_premium_user_skips_or(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -602,9 +653,9 @@ async def test_tier_a_falls_through_to_or_when_a_pool_empty_for_user(monkeypatch
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -676,9 +727,9 @@ async def test_top_k_picks_only_high_score_models(monkeypatch):
|
|||
"quality_score": 10,
|
||||
"health_gated": False,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[*high_score_cfgs, low_score_trap],
|
||||
)
|
||||
|
||||
|
|
@ -723,9 +774,9 @@ async def test_pin_reuse_survives_health_gating_for_existing_pin(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -775,9 +826,9 @@ async def test_pin_reuse_regression_existing_healthy_pin(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -833,9 +884,9 @@ async def test_runtime_cooled_down_pin_is_not_reused(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -886,9 +937,9 @@ async def test_clearing_runtime_cooldown_restores_pin_reuse(monkeypatch):
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
@ -931,9 +982,9 @@ async def test_auto_pin_repin_excludes_previous_config_on_runtime_retry(monkeypa
|
|||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread(pinned_llm_config_id=-1))
|
||||
monkeypatch.setattr(
|
||||
_set_global_llm_configs(
|
||||
monkeypatch,
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
|
|
|
|||
|
|
@ -15,15 +15,19 @@ async def test_global_openrouter_image_gen_sets_explicit_api_base():
|
|||
"""The global-config branch forwards the explicit OpenRouter base."""
|
||||
from app.routes import image_generation_routes
|
||||
|
||||
cfg = {
|
||||
global_model = {
|
||||
"id": -20_001,
|
||||
"name": "GPT Image 1 (OpenRouter)",
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "openai/gpt-image-1",
|
||||
"connection_id": -101,
|
||||
"model_id": "openai/gpt-image-1",
|
||||
"supports_image_generation": True,
|
||||
"capabilities_override": {},
|
||||
}
|
||||
global_connection = {
|
||||
"id": -101,
|
||||
"provider": "openrouter",
|
||||
"api_key": "sk-or-test",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"api_version": None,
|
||||
"litellm_params": {},
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"extra": {},
|
||||
}
|
||||
|
||||
captured: dict = {}
|
||||
|
|
@ -33,7 +37,7 @@ async def test_global_openrouter_image_gen_sets_explicit_api_base():
|
|||
return MagicMock(model_dump=lambda: {"data": []}, _hidden_params={})
|
||||
|
||||
image_gen = MagicMock()
|
||||
image_gen.image_generation_config_id = cfg["id"]
|
||||
image_gen.image_gen_model_id = global_model["id"]
|
||||
image_gen.prompt = "test"
|
||||
image_gen.n = 1
|
||||
image_gen.quality = None
|
||||
|
|
@ -43,14 +47,19 @@ async def test_global_openrouter_image_gen_sets_explicit_api_base():
|
|||
image_gen.model = None
|
||||
|
||||
search_space = MagicMock()
|
||||
search_space.image_generation_config_id = cfg["id"]
|
||||
search_space.image_gen_model_id = global_model["id"]
|
||||
session = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
image_generation_routes,
|
||||
"_get_global_image_gen_config",
|
||||
return_value=cfg,
|
||||
"_get_global_model",
|
||||
return_value=global_model,
|
||||
),
|
||||
patch.object(
|
||||
image_generation_routes,
|
||||
"_get_global_connection",
|
||||
return_value=global_connection,
|
||||
),
|
||||
patch.object(
|
||||
image_generation_routes,
|
||||
|
|
@ -74,15 +83,19 @@ async def test_generate_image_tool_global_sets_explicit_api_base():
|
|||
generate_image as gi_module,
|
||||
)
|
||||
|
||||
cfg = {
|
||||
global_model = {
|
||||
"id": -20_001,
|
||||
"name": "GPT Image 1 (OpenRouter)",
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "openai/gpt-image-1",
|
||||
"connection_id": -101,
|
||||
"model_id": "openai/gpt-image-1",
|
||||
"supports_image_generation": True,
|
||||
"capabilities_override": {},
|
||||
}
|
||||
global_connection = {
|
||||
"id": -101,
|
||||
"provider": "openrouter",
|
||||
"api_key": "sk-or-test",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"api_version": None,
|
||||
"litellm_params": {},
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"extra": {},
|
||||
}
|
||||
|
||||
captured: dict = {}
|
||||
|
|
@ -98,7 +111,7 @@ async def test_generate_image_tool_global_sets_explicit_api_base():
|
|||
|
||||
search_space = MagicMock()
|
||||
search_space.id = 1
|
||||
search_space.image_generation_config_id = cfg["id"]
|
||||
search_space.image_gen_model_id = global_model["id"]
|
||||
|
||||
session_cm = AsyncMock()
|
||||
session = AsyncMock()
|
||||
|
|
@ -121,7 +134,8 @@ async def test_generate_image_tool_global_sets_explicit_api_base():
|
|||
|
||||
with (
|
||||
patch.object(gi_module, "shielded_async_session", return_value=session_cm),
|
||||
patch.object(gi_module, "_get_global_image_gen_config", return_value=cfg),
|
||||
patch.object(gi_module, "_get_global_model", return_value=global_model),
|
||||
patch.object(gi_module, "_get_global_connection", return_value=global_connection),
|
||||
patch.object(
|
||||
gi_module, "aimage_generation", side_effect=fake_aimage_generation
|
||||
),
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ def test_generate_configs_drops_non_text_and_non_tool_models():
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _generate_image_gen_configs / _generate_vision_llm_configs
|
||||
# _generate_image_gen_configs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -263,7 +263,7 @@ def test_generate_image_gen_configs_filters_by_image_output():
|
|||
# Each config must carry ``billing_tier`` for routing in image_generation_routes.
|
||||
for c in cfgs:
|
||||
assert c["billing_tier"] in {"free", "premium"}
|
||||
assert c["litellm_provider"] == "openrouter"
|
||||
assert c["provider"] == "openrouter"
|
||||
assert c[_OPENROUTER_DYNAMIC_MARKER] is True
|
||||
# Emit the OpenRouter base URL at source so every call path passes an
|
||||
# explicit api_base and cannot inherit a process-global endpoint.
|
||||
|
|
@ -271,9 +271,7 @@ def test_generate_image_gen_configs_filters_by_image_output():
|
|||
|
||||
|
||||
def test_generate_image_gen_configs_assigns_image_id_offset():
|
||||
"""Image configs use a different id_offset (-20000) so their negative
|
||||
IDs don't collide with chat configs (-10000) or vision configs (-30000).
|
||||
"""
|
||||
"""Image configs use their own id_offset (-20000)."""
|
||||
from app.services.openrouter_integration_service import (
|
||||
_generate_image_gen_configs,
|
||||
)
|
||||
|
|
@ -291,88 +289,3 @@ def test_generate_image_gen_configs_assigns_image_id_offset():
|
|||
assert all(c["id"] < -20_000 + 1 for c in cfgs)
|
||||
assert all(c["id"] > -29_000_000 for c in cfgs)
|
||||
|
||||
|
||||
def test_generate_vision_llm_configs_filters_by_image_input_text_output():
|
||||
"""Vision LLMs must accept image input AND emit text — pure image-gen
|
||||
(no text out) and text-only (no image in) models are excluded.
|
||||
"""
|
||||
from app.services.openrouter_integration_service import (
|
||||
_generate_vision_llm_configs,
|
||||
)
|
||||
|
||||
raw = [
|
||||
# GPT-4o: vision LLM (image in, text out) — must emit.
|
||||
{
|
||||
"id": "openai/gpt-4o",
|
||||
"architecture": {
|
||||
"input_modalities": ["text", "image"],
|
||||
"output_modalities": ["text"],
|
||||
},
|
||||
"context_length": 128_000,
|
||||
"pricing": {"prompt": "0.000005", "completion": "0.000015"},
|
||||
},
|
||||
# Pure image generator — image *output*, no text out. Must NOT emit.
|
||||
{
|
||||
"id": "openai/gpt-image-1",
|
||||
"architecture": {
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["image"],
|
||||
},
|
||||
"context_length": 4_000,
|
||||
"pricing": {"prompt": "0", "completion": "0"},
|
||||
},
|
||||
# Pure text model (no image in). Must NOT emit.
|
||||
{
|
||||
"id": "anthropic/claude-3-haiku",
|
||||
"architecture": {
|
||||
"input_modalities": ["text"],
|
||||
"output_modalities": ["text"],
|
||||
},
|
||||
"context_length": 200_000,
|
||||
"pricing": {"prompt": "0.000001", "completion": "0.000005"},
|
||||
},
|
||||
]
|
||||
|
||||
cfgs = _generate_vision_llm_configs(raw, dict(_SETTINGS_BASE))
|
||||
names = {c["model_name"] for c in cfgs}
|
||||
assert names == {"openai/gpt-4o"}
|
||||
|
||||
cfg = cfgs[0]
|
||||
assert cfg["billing_tier"] == "premium"
|
||||
# Pricing carried inline so pricing_registration can register vision
|
||||
# under ``openrouter/openai/gpt-4o`` even if the chat catalogue cache
|
||||
# is cleared.
|
||||
assert cfg["input_cost_per_token"] == pytest.approx(5e-6)
|
||||
assert cfg["output_cost_per_token"] == pytest.approx(15e-6)
|
||||
assert cfg[_OPENROUTER_DYNAMIC_MARKER] is True
|
||||
# Emit the OpenRouter base URL at source so every call path passes an
|
||||
# explicit api_base and cannot inherit a process-global endpoint.
|
||||
assert cfg["api_base"] == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def test_generate_vision_llm_configs_drops_chat_only_filters():
|
||||
"""A small-context vision model that doesn't advertise tool calling is
|
||||
still a valid vision LLM for "describe this image" prompts. The chat
|
||||
filters (``supports_tool_calling``, ``has_sufficient_context``) must
|
||||
NOT be applied to vision emission.
|
||||
"""
|
||||
from app.services.openrouter_integration_service import (
|
||||
_generate_vision_llm_configs,
|
||||
)
|
||||
|
||||
raw = [
|
||||
{
|
||||
"id": "tiny/vision-mini",
|
||||
"architecture": {
|
||||
"input_modalities": ["text", "image"],
|
||||
"output_modalities": ["text"],
|
||||
},
|
||||
"supported_parameters": [], # no tools
|
||||
"context_length": 4_000, # well below MIN_CONTEXT_LENGTH
|
||||
"pricing": {"prompt": "0.0000001", "completion": "0.0000005"},
|
||||
}
|
||||
]
|
||||
|
||||
cfgs = _generate_vision_llm_configs(raw, dict(_SETTINGS_BASE))
|
||||
assert len(cfgs) == 1
|
||||
assert cfgs[0]["model_name"] == "tiny/vision-mini"
|
||||
|
|
|
|||
|
|
@ -370,77 +370,3 @@ def test_register_continues_after_individual_failure(monkeypatch, caplog):
|
|||
assert any("custom-deployment" in payload for payload in successful_calls)
|
||||
|
||||
|
||||
def test_vision_configs_registered_with_chat_shape(monkeypatch):
|
||||
"""``register_pricing_from_global_configs`` walks
|
||||
``GLOBAL_VISION_LLM_CONFIGS`` in addition to the chat configs so vision
|
||||
calls (during indexing) bill correctly. Vision configs use the same
|
||||
chat-shape token prices, but image-gen pricing is intentionally NOT
|
||||
registered here (handled via ``response_cost`` in LiteLLM).
|
||||
"""
|
||||
from app.config import config
|
||||
from app.services.pricing_registration import register_pricing_from_global_configs
|
||||
|
||||
spy = _patch_register(monkeypatch)
|
||||
_patch_openrouter_pricing(
|
||||
monkeypatch,
|
||||
{"openai/gpt-4o": {"prompt": "0.000005", "completion": "0.000015"}},
|
||||
)
|
||||
|
||||
# No chat configs — only vision. Proves the vision walk is a separate
|
||||
# iteration, not piggy-backed on the chat list.
|
||||
monkeypatch.setattr(config, "GLOBAL_LLM_CONFIGS", [])
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"GLOBAL_VISION_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "openai/gpt-4o",
|
||||
"billing_tier": "premium",
|
||||
"input_cost_per_token": 5e-6,
|
||||
"output_cost_per_token": 15e-6,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
register_pricing_from_global_configs()
|
||||
|
||||
assert "openrouter/openai/gpt-4o" in spy.all_keys
|
||||
payload_value = spy.calls[0]["openrouter/openai/gpt-4o"]
|
||||
assert payload_value["mode"] == "chat"
|
||||
assert payload_value["litellm_provider"] == "openrouter"
|
||||
assert payload_value["input_cost_per_token"] == pytest.approx(5e-6)
|
||||
assert payload_value["output_cost_per_token"] == pytest.approx(15e-6)
|
||||
|
||||
|
||||
def test_vision_with_inline_pricing_when_or_cache_missing(monkeypatch):
|
||||
"""If the OpenRouter pricing cache misses a vision model (different
|
||||
catalogue surface), the vision walk falls back to inline
|
||||
``input_cost_per_token``/``output_cost_per_token`` on the cfg itself.
|
||||
"""
|
||||
from app.config import config
|
||||
from app.services.pricing_registration import register_pricing_from_global_configs
|
||||
|
||||
spy = _patch_register(monkeypatch)
|
||||
_patch_openrouter_pricing(monkeypatch, {})
|
||||
|
||||
monkeypatch.setattr(config, "GLOBAL_LLM_CONFIGS", [])
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"GLOBAL_VISION_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "google/gemini-2.5-flash",
|
||||
"billing_tier": "premium",
|
||||
"input_cost_per_token": 1e-6,
|
||||
"output_cost_per_token": 4e-6,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
register_pricing_from_global_configs()
|
||||
|
||||
assert "openrouter/google/gemini-2.5-flash" in spy.all_keys
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Unit tests for the Auto (Fastest) quality scoring module."""
|
||||
"""Unit tests for the Auto quality scoring module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
"""Vision LLM resolution must pass explicit per-config ``api_base``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vision_llm_global_openrouter_sets_api_base():
|
||||
"""Global negative-ID branch forwards the explicit OpenRouter base."""
|
||||
from app.services import llm_service
|
||||
|
||||
cfg = {
|
||||
"id": -30_001,
|
||||
"name": "GPT-4o Vision (OpenRouter)",
|
||||
"litellm_provider": "openrouter",
|
||||
"model_name": "openai/gpt-4o",
|
||||
"api_key": "sk-or-test",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"api_version": None,
|
||||
"litellm_params": {},
|
||||
"billing_tier": "free",
|
||||
}
|
||||
|
||||
search_space = MagicMock()
|
||||
search_space.id = 1
|
||||
search_space.user_id = "user-x"
|
||||
search_space.vision_llm_config_id = cfg["id"]
|
||||
|
||||
session = AsyncMock()
|
||||
scalars = MagicMock()
|
||||
scalars.first.return_value = search_space
|
||||
result = MagicMock()
|
||||
result.scalars.return_value = scalars
|
||||
session.execute.return_value = result
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class FakeSanitized:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.vision_llm_router_service.get_global_vision_llm_config",
|
||||
return_value=cfg,
|
||||
),
|
||||
patch(
|
||||
"app.agents.chat.runtime.llm_config.SanitizedChatLiteLLM",
|
||||
new=FakeSanitized,
|
||||
),
|
||||
):
|
||||
await llm_service.get_vision_llm(session=session, search_space_id=1)
|
||||
|
||||
assert captured.get("api_base") == "https://openrouter.ai/api/v1"
|
||||
assert captured["model"] == "openrouter/openai/gpt-4o"
|
||||
|
||||
|
||||
def test_vision_router_deployment_sets_api_base_when_config_empty():
|
||||
"""Auto-mode vision router carries explicit api_base into deployments."""
|
||||
from app.services.vision_llm_router_service import VisionLLMRouterService
|
||||
|
||||
deployment = VisionLLMRouterService._config_to_deployment(
|
||||
{
|
||||
"model_name": "openai/gpt-4o",
|
||||
"litellm_provider": "openrouter",
|
||||
"api_key": "sk-or-test",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
)
|
||||
assert deployment is not None
|
||||
assert deployment["litellm_params"]["api_base"] == "https://openrouter.ai/api/v1"
|
||||
assert deployment["litellm_params"]["model"] == "openrouter/openai/gpt-4o"
|
||||
Loading…
Add table
Add a link
Reference in a new issue