diff --git a/strix/config/models.py b/strix/config/models.py index 102d132cf..105627476 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -1016,22 +1016,30 @@ def model_supports_images(model_name: str) -> bool: def _bare_openai_name(model_name: str) -> str: - name = model_name.strip().lower() + name = model_name.strip() + normalized = name.lower() for prefix in ("litellm/", "any-llm/", "openai/"): - if name.startswith(prefix): + if normalized.startswith(prefix): return name[len(prefix) :] return name def _catalog_entry(model_name: str) -> dict[str, Any] | None: - """LiteLLM's cost-map entry for the model, looked up as it would route it.""" + """LiteLLM's case-sensitive cost-map entry for the model and its aliases.""" import litellm - name = _bare_openai_name(model_name) - entry = litellm.model_cost.get(name) - if entry is None and "/" in name: - entry = litellm.model_cost.get(name.rsplit("/", 1)[1]) - return entry if isinstance(entry, dict) else None + supplied_name = model_name.strip() + routed_name = _bare_openai_name(supplied_name) + candidates = [supplied_name, routed_name, routed_name.lower()] + if "/" in routed_name: + bare_name = routed_name.rsplit("/", 1)[1] + candidates.extend((bare_name, bare_name.lower())) + + for candidate in dict.fromkeys(candidates): + entry = litellm.model_cost.get(candidate) + if entry is not None: + return entry if isinstance(entry, dict) else None + return None def is_known_openai_bare_model(model_name: str) -> bool: diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 870dc4d1d..537d80a63 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -156,6 +156,22 @@ def test_max_reasoning_effort_sent_as_raw_body_field() -> None: assert settings.extra_body == {"reasoning_effort": "max"} +def test_mixed_case_reasoning_capability_reaches_model_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model = "deepinfra/deepseek-ai/DeepSeek-V4-Flash-0731" + monkeypatch.setattr( + litellm, + "model_cost", + {model: {"supports_reasoning": True}}, + ) + + settings = make_model_settings("high", model_name=model) + + assert settings.reasoning is not None + assert settings.reasoning.effort == "high" + + def test_conversation_tail_breakpoint_moves_with_appended_transcript() -> None: # LiteLLM must place the index=-1 cache_control on the last message however # long the transcript grows. diff --git a/tests/test_models.py b/tests/test_models.py index aafbaf40c..f3d17b964 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -17,6 +17,7 @@ from strix.config.models import ( _TurnGuardModel, configure_sdk_model_defaults, model_supports_images, + model_supports_reasoning, request_timeout_extra_args, resolve_api_type, routes_through_litellm, @@ -211,3 +212,136 @@ def test_image_support_follows_the_catalog(monkeypatch: pytest.MonkeyPatch) -> N assert model_supports_images("acme-text") is False assert model_supports_images("litellm/openai/acme-vision") is True assert model_supports_images("acme-unknown-model") is True + + +_MIXED_CASE_MODEL = "deepinfra/deepseek-ai/DeepSeek-V4-Flash-0731" + + +def test_mixed_case_model_capabilities_follow_the_pinned_litellm_catalog() -> None: + """Integration check against the real catalog pinned by uv.lock.""" + entry = litellm.model_cost[_MIXED_CASE_MODEL] + + assert entry["supports_reasoning"] is True + assert entry["supports_vision"] is False + assert model_supports_reasoning(_MIXED_CASE_MODEL) is True + assert model_supports_images(_MIXED_CASE_MODEL) is False + + +@pytest.mark.parametrize("route_prefix", ["litellm/", "any-llm/", "openai/"]) +def test_catalog_lookup_strips_supported_route_prefix_without_losing_case( + monkeypatch: pytest.MonkeyPatch, route_prefix: str +) -> None: + monkeypatch.setattr( + litellm, + "model_cost", + {"vendor/MixedCase": {"supports_reasoning": True, "supports_vision": False}}, + ) + + model = f"{route_prefix}vendor/MixedCase" + assert model_supports_reasoning(model) is True + assert model_supports_images(model) is False + + +def test_catalog_lookup_prefers_exact_key_over_normalized_and_bare_fallbacks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model = "openai/vendor/MixedCase" + monkeypatch.setattr( + litellm, + "model_cost", + { + model: { + "supports_reasoning": False, + "supports_vision": False, + "supported_endpoints": ["/v1/chat/completions"], + }, + model.lower(): { + "supports_reasoning": True, + "supports_vision": True, + "supported_endpoints": ["/v1/responses"], + }, + "MixedCase": { + "supports_reasoning": True, + "supports_vision": True, + "supported_endpoints": ["/v1/responses"], + }, + }, + ) + settings = _settings(monkeypatch, model, None) + + assert model_supports_reasoning(model) is False + assert model_supports_images(model) is False + assert resolve_api_type(model, settings) == "chat_completions" + assert uses_chat_completions_tool_schema(model, settings) is True + + +def test_catalog_lookup_prefers_case_preserving_stripped_key_over_normalized_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + litellm, + "model_cost", + { + "vendor/MixedCase": {"supports_reasoning": False, "supports_vision": False}, + "vendor/mixedcase": {"supports_reasoning": True, "supports_vision": True}, + }, + ) + + model = "litellm/vendor/MixedCase" + assert model_supports_reasoning(model) is False + assert model_supports_images(model) is False + + +@pytest.mark.parametrize( + ("model", "catalog_key"), + [ + ("vendor/lowercase", "vendor/lowercase"), + ("vendor/MixedCase", "vendor/mixedcase"), + ("vendor/MixedCase", "MixedCase"), + ], + ids=["lowercase-exact", "normalized-fallback", "bare-alias"], +) +def test_catalog_lookup_compatibility_fallbacks( + monkeypatch: pytest.MonkeyPatch, model: str, catalog_key: str +) -> None: + monkeypatch.setattr( + litellm, + "model_cost", + {catalog_key: {"supports_reasoning": True, "supports_vision": False}}, + ) + + assert model_supports_reasoning(model) is True + assert model_supports_images(model) is False + + +def test_unknown_model_capability_defaults_are_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "model_cost", {}) + settings = _settings(monkeypatch, "openai/UnknownMixedCase", None) + + assert model_supports_reasoning("openai/UnknownMixedCase") is False + assert model_supports_images("openai/UnknownMixedCase") is True + assert resolve_api_type("openai/UnknownMixedCase", settings) == "chat_completions" + + +@pytest.mark.parametrize( + ("model", "supported_endpoints", "expected"), + [ + ("openai/AcmeResponses", ["/v1/responses"], "responses"), + ("openai/AcmeChat", ["/v1/chat/completions"], "chat_completions"), + ], +) +def test_api_type_follows_exact_mixed_case_catalog_capability( + monkeypatch: pytest.MonkeyPatch, + model: str, + supported_endpoints: list[str], + expected: str, +) -> None: + monkeypatch.setattr( + litellm, + "model_cost", + {model: {"supported_endpoints": supported_endpoints}}, + ) + settings = _settings(monkeypatch, model, None) + + assert resolve_api_type(model, settings) == expected + assert uses_chat_completions_tool_schema(model, settings) is (expected == "chat_completions")