diff --git a/strix/config/models.py b/strix/config/models.py index f6848ca4c..a5422a3b1 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -466,6 +466,11 @@ def _resolve_prefixed_model( ) if prefix == "ollama" and stripped_model_name: return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}" + if prefix == "llmtr" and stripped_model_name: + # LLMTR is an OpenAI-compatible gateway, so route llmtr/ + # through LiteLLM's openai/ provider. The gateway base URL and + # attribution headers are configured in _configure_llmtr_routing. + return self._get_fallback_provider("litellm"), f"openai/{stripped_model_name}" return self._get_fallback_provider("litellm"), original_model_name def get_model(self, model_name: str | None) -> Model: @@ -560,6 +565,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None: return _configure_litellm_compatibility() _configure_openrouter_attribution(llm.model) + _configure_llmtr_routing(llm.model, llm.api_base) if llm.api_key: set_default_openai_key(llm.api_key, use_for_tracing=False) _configure_litellm_default("api_key", llm.api_key) @@ -679,6 +685,39 @@ def _configure_openrouter_attribution(model_name: str | None) -> None: litellm.headers = {**existing, **OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment] +LLMTR_API_BASE = "https://llmtr.com/v1" + +LLMTR_ATTRIBUTION_HEADERS = { + "HTTP-Referer": "https://strix.ai", + "X-Title": "Strix", +} + + +def is_llmtr_model(model_name: str | None) -> bool: + return bool(model_name) and (model_name or "").strip().lower().startswith("llmtr/") + + +def _configure_llmtr_routing(model_name: str | None, api_base: str | None) -> None: + """Make ``llmtr/`` a first-class prefix for the LLMTR gateway. + + LLMTR (https://llmtr.com) is a Turkey-hosted OpenAI-compatible gateway, so + ``StrixProvider`` resolves ``llmtr/`` to LiteLLM's ``openai/`` + route. That route needs the gateway base URL, which is supplied here (an + explicit ``LLM_API_BASE`` still wins, for a proxy in front of LLMTR), along + with Strix attribution headers so scans are identifiable in the LLMTR + dashboard. Users set only ``STRIX_LLM`` and ``LLM_API_KEY``. + """ + if not is_llmtr_model(model_name): + return + import litellm + + if not api_base: + _configure_litellm_default("api_base", LLMTR_API_BASE) + current: object = litellm.headers + existing: dict[str, str] = current if isinstance(current, dict) else {} + litellm.headers = {**existing, **LLMTR_ATTRIBUTION_HEADERS} # type: ignore[assignment] + + def _configure_extra_headers(llm: LlmSettings) -> None: """Send user-provided default headers on every LLM request. diff --git a/strix/core/inputs.py b/strix/core/inputs.py index ea72abb7f..90bb4ff9a 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -10,11 +10,13 @@ from strix.config.models import ( DEFAULT_MODEL_RETRY, + LLMTR_ATTRIBUTION_HEADERS, OPENROUTER_ATTRIBUTION_HEADERS, bedrock_route_supports_prompt_caching, is_bedrock_route, is_claude_model, is_known_openai_bare_model, + is_llmtr_model, is_openrouter_model, model_supports_reasoning, request_timeout_extra_args, @@ -271,6 +273,8 @@ def _request_headers( headers: dict[str, str] = {} if is_openrouter_model(model_name): headers.update(OPENROUTER_ATTRIBUTION_HEADERS) + if is_llmtr_model(model_name): + headers.update(LLMTR_ATTRIBUTION_HEADERS) if extra_headers: headers.update(extra_headers) return headers or None diff --git a/strix/report/dedupe.py b/strix/report/dedupe.py index 1cc0a66a1..d97e0af31 100644 --- a/strix/report/dedupe.py +++ b/strix/report/dedupe.py @@ -13,8 +13,10 @@ from strix.config import load_settings from strix.config.models import ( + LLMTR_API_BASE, StrixProvider, configure_sdk_model_defaults, + is_llmtr_model, ) from strix.core.inputs import make_model_settings from strix.report.state import get_global_report_state @@ -45,6 +47,12 @@ def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]: extra["api_key"] = dedupe.api_key.strip() if dedupe.api_base and dedupe.api_base.strip(): extra["api_base"] = dedupe.api_base.strip() + elif is_llmtr_model(dedupe.model): + # A dedicated llmtr/ dedupe model reaches the LLMTR gateway only through + # its endpoint. The global default is derived from the main model, which + # may be a different provider, so pin the gateway per call here rather + # than relying on the shared litellm.api_base. + extra["api_base"] = LLMTR_API_BASE return extra diff --git a/tests/test_dedupe_model.py b/tests/test_dedupe_model.py index b17946e9b..2466d8519 100644 --- a/tests/test_dedupe_model.py +++ b/tests/test_dedupe_model.py @@ -7,7 +7,7 @@ from strix.config import loader from strix.config.settings import DedupeSettings -from strix.report.dedupe import _dedupe_model_settings +from strix.report.dedupe import _dedupe_extra_args, _dedupe_model_settings if TYPE_CHECKING: @@ -44,6 +44,35 @@ def test_dedupe_endpoint_sent_per_call() -> None: assert (settings.extra_args or {})["api_key"] == "dedupe-key" +def test_llmtr_dedupe_model_pins_gateway_endpoint_per_call() -> None: + # A dedicated llmtr/ dedupe model reaches the gateway only via its endpoint. + # The global default is keyed to the main model, which may be a different + # provider, so the endpoint must ride on the dedupe request itself. + dedupe = DedupeSettings( + STRIX_DEDUPE_MODEL="llmtr/openai/gpt-5.5", + DEDUPE_LLM_API_KEY="llmtr-key", + ) + extra = _dedupe_extra_args(dedupe) + assert extra["api_base"] == "https://llmtr.com/v1" + assert extra["api_key"] == "llmtr-key" + + +def test_llmtr_dedupe_model_respects_explicit_endpoint() -> None: + # An explicit DEDUPE_LLM_API_BASE (e.g. a proxy in front of LLMTR) wins over + # the gateway default. + dedupe = DedupeSettings( + STRIX_DEDUPE_MODEL="llmtr/openai/gpt-5.5", + DEDUPE_LLM_API_KEY="llmtr-key", + DEDUPE_LLM_API_BASE="https://proxy.example/v1", + ) + assert _dedupe_extra_args(dedupe)["api_base"] == "https://proxy.example/v1" + + +def test_non_llmtr_dedupe_model_gets_no_injected_endpoint() -> None: + dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="openai/gpt-5.5", DEDUPE_LLM_API_KEY="k") + assert "api_base" not in _dedupe_extra_args(dedupe) + + def test_dedicated_dedupe_model_uses_own_headers_not_main() -> None: dedupe = DedupeSettings( STRIX_DEDUPE_MODEL="deepseek/cheap", diff --git a/tests/test_models.py b/tests/test_models.py index 04bb28755..df1004792 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,11 +2,18 @@ from __future__ import annotations +from typing import cast + +import litellm import pytest from agents.model_settings import ModelSettings from strix.config.models import ( + LLMTR_API_BASE, RECOMMENDED_MODEL_NAMES, + StrixProvider, + _configure_llmtr_routing, + is_llmtr_model, is_recommended_or_frontier_model, request_timeout_extra_args, supports_strict_tool_schemas, @@ -112,3 +119,99 @@ def test_claude_routes_reject_strict_tool_schemas(model_name: str) -> None: ) def test_other_routes_keep_strict_tool_schemas(model_name: str) -> None: assert supports_strict_tool_schemas(model_name) + + +@pytest.mark.parametrize( + ("model_name", "expected"), + [ + ("llmtr/anthropic/claude-sonnet-5", True), + ("llmtr/openai/gpt-5.5", True), + ("llmtr/llmtr/gemma-4", True), + ("LLMTR/openai/gpt-5.5", True), + (" llmtr/openai/gpt-5.5 ", True), + ("openrouter/anthropic/claude-sonnet-5", False), + ("openai/gpt-5.4", False), + ("anthropic/claude-sonnet-5", False), + ("", False), + (None, False), + ], +) +def test_is_llmtr_model(model_name: str | None, expected: bool) -> None: + assert bool(is_llmtr_model(model_name)) is expected + + +def test_llmtr_prefix_routes_through_openai_compatible_litellm() -> None: + provider = StrixProvider() + + resolved_provider, resolved_model = provider._resolve_prefixed_model( + original_model_name="llmtr/anthropic/claude-sonnet-5", + prefix="llmtr", + stripped_model_name="anthropic/claude-sonnet-5", + ) + + assert resolved_model == "openai/anthropic/claude-sonnet-5" + assert type(resolved_provider).__name__ == "LitellmProvider" + + +def test_llmtr_prefix_preserves_nested_gateway_namespace() -> None: + provider = StrixProvider() + + _resolved_provider, resolved_model = provider._resolve_prefixed_model( + original_model_name="llmtr/llmtr/gemma-4", + prefix="llmtr", + stripped_model_name="llmtr/gemma-4", + ) + + assert resolved_model == "openai/llmtr/gemma-4" + + +def test_configure_llmtr_routing_sets_base_url_and_attribution() -> None: + saved_base, saved_headers = litellm.api_base, litellm.headers + try: + litellm.api_base = None + litellm.headers = None + _configure_llmtr_routing("llmtr/anthropic/claude-sonnet-5", None) + assert litellm.api_base == LLMTR_API_BASE + headers = cast("dict[str, str]", litellm.headers) + assert headers["HTTP-Referer"] == "https://strix.ai" + assert headers["X-Title"] == "Strix" + finally: + litellm.api_base, litellm.headers = saved_base, saved_headers + + +def test_configure_llmtr_routing_respects_explicit_api_base() -> None: + saved_base, saved_headers = litellm.api_base, litellm.headers + try: + litellm.api_base = None + litellm.headers = None + _configure_llmtr_routing("llmtr/anthropic/claude-sonnet-5", "https://proxy/v1") + # An explicit LLM_API_BASE wins; the LLMTR default must not override it. + assert litellm.api_base is None + headers = cast("dict[str, str]", litellm.headers) + assert headers["X-Title"] == "Strix" + finally: + litellm.api_base, litellm.headers = saved_base, saved_headers + + +def test_configure_llmtr_routing_is_noop_for_other_providers() -> None: + saved_base, saved_headers = litellm.api_base, litellm.headers + try: + litellm.api_base = None + litellm.headers = None + _configure_llmtr_routing("openrouter/anthropic/claude-sonnet-5", None) + assert litellm.api_base is None + assert litellm.headers is None + finally: + litellm.api_base, litellm.headers = saved_base, saved_headers + + +@pytest.mark.parametrize( + "model_name", + [ + "llmtr/anthropic/claude-opus-5", + "llmtr/anthropic/claude-sonnet-5", + "llmtr/openai/gpt-5.5", + ], +) +def test_llmtr_frontier_models_are_accepted(model_name: str) -> None: + assert is_recommended_or_frontier_model(model_name)