diff --git a/strix/config/models.py b/strix/config/models.py index f6848ca4..a5422a3b 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -466,6 +466,11 @@ class StrixProvider(MultiProvider): ) 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 ea72abb7..90bb4ff9 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -10,11 +10,13 @@ from openai.types.shared import Reasoning 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/tests/test_models.py b/tests/test_models.py index 04bb2875..df100479 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)