From 6ab123484d692bc470eb0eccab2b3e803809e025 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Fri, 2 Oct 2026 12:50:06 +0000 Subject: [PATCH] fix(config): choose Responses vs chat completions from the model, not the base URL A base URL no longer forces chat completions. resolve_api_type() keeps an explicit STRIX_API_TYPE, uses Responses without a base URL or for api.openai.com, uses Responses for models whose LiteLLM catalog entry has no /v1/chat/completions endpoint, and chat completions for other gateways. On the chat completions route, reasoning_effort is omitted for models whose LiteLLM parameter map does not list it there instead of failing the request with function tools. STRIX_REASONING_EFFORT and STRIX_API_TYPE are matched case-insensitively. --- strix/config/models.py | 90 ++++++++++++++++++++++++++++++++++------ strix/config/settings.py | 18 +++++++- strix/core/inputs.py | 31 ++++++++++++-- strix/core/runner.py | 2 + tests/test_inputs.py | 28 +++++++++++++ tests/test_models.py | 64 ++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 18 deletions(-) diff --git a/strix/config/models.py b/strix/config/models.py index e8ac0632..ea846f2a 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -58,7 +58,7 @@ if TYPE_CHECKING: from openai import AsyncOpenAI from openai.types.responses.response_prompt_param import ResponsePromptParam - from strix.config.settings import LlmSettings, ReasoningEffort, Settings + from strix.config.settings import ApiType, LlmSettings, ReasoningEffort, Settings logger = logging.getLogger(__name__) @@ -649,14 +649,70 @@ def configure_sdk_model_defaults(settings: Settings) -> None: if llm.api_base: os.environ["OPENAI_BASE_URL"] = llm.api_base _configure_litellm_default("api_base", llm.api_base) - api_type = llm.api_type - if api_type is None: - api_type = "chat_completions" if llm.api_base else "responses" - + api_type = resolve_api_type(llm.model or "", settings) + logger.info("OpenAI API route: %s", api_type) set_default_openai_api(api_type) _configure_extra_headers(llm) +_OPENAI_HOSTS = frozenset({"api.openai.com"}) +_CHAT_COMPLETIONS_ENDPOINT = "/v1/chat/completions" + + +def resolve_api_type(model_name: str, settings: Settings) -> ApiType: + """The SDK-native OpenAI route for ``model_name``: Responses or chat completions. + + An explicit ``STRIX_API_TYPE`` wins. Without a base URL the request goes to + OpenAI itself, where every model is served by the Responses API. With a base + URL the model decides, not the URL: a model whose catalog entry lists no + ``/v1/chat/completions`` endpoint cannot be reached there at all, and + api.openai.com behind a base URL is still OpenAI. Anything else is an + OpenAI-compatible gateway whose common denominator is chat completions. + """ + if settings.llm.api_type is not None: + return settings.llm.api_type + api_base = (settings.llm.api_base or "").strip() + if not api_base or _is_openai_host(api_base): + return "responses" + endpoints = _catalog_supported_endpoints(model_name) + if endpoints and _CHAT_COMPLETIONS_ENDPOINT not in endpoints: + return "responses" + return "chat_completions" + + +def _is_openai_host(api_base: str) -> bool: + from urllib.parse import urlsplit + + return (urlsplit(api_base).hostname or "").lower() in _OPENAI_HOSTS + + +def _catalog_supported_endpoints(model_name: str) -> list[str]: + entry = _catalog_entry(model_name) + endpoints = entry.get("supported_endpoints") if entry else None + return [str(e) for e in endpoints] if isinstance(endpoints, list) else [] + + +def chat_completions_accept_reasoning_effort(model_name: str) -> bool: + """Whether OpenAI's chat completions take ``reasoning_effort`` for ``model_name``. + + Some reasoning models accept the parameter on the Responses API only and + reject a chat completion carrying it (with function tools). LiteLLM's + parameter map records which; a model it does not know is given the benefit + of the doubt. + """ + if not _catalog_entry(model_name): + return True + import litellm + + try: + params = litellm.get_supported_openai_params( + _bare_openai_name(model_name), custom_llm_provider="openai" + ) + except Exception: # noqa: BLE001 - an unmapped model keeps the parameter + return True + return "reasoning_effort" in (params or []) + + def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None: if not model_name: return @@ -859,10 +915,8 @@ def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bo model = model_name.strip().lower() if "/" in model and not model.startswith("openai/"): return True - if settings.llm.api_type is not None: - return settings.llm.api_type == "chat_completions" - if settings.llm.api_base: - return True + if settings.llm.api_type is not None or settings.llm.api_base: + return resolve_api_type(model_name, settings) == "chat_completions" return not model_supports_reasoning(model_name) @@ -879,17 +933,27 @@ def supports_strict_tool_schemas(model_name: str) -> bool: def model_supports_reasoning(model_name: str) -> bool: - import litellm + entry = _catalog_entry(model_name) + return bool(entry and entry.get("supports_reasoning")) + +def _bare_openai_name(model_name: str) -> str: name = model_name.strip().lower() for prefix in ("litellm/", "any-llm/", "openai/"): if name.startswith(prefix): - name = name[len(prefix) :] - break + 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.""" + 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 bool(entry and entry.get("supports_reasoning")) + return entry if isinstance(entry, dict) else None def is_known_openai_bare_model(model_name: str) -> bool: diff --git a/strix/config/settings.py b/strix/config/settings.py index 73907c9c..d0474612 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Literal -from pydantic import AliasChoices, Field +from pydantic import AliasChoices, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -13,6 +13,12 @@ ApiType = Literal["responses", "chat_completions"] DEFAULT_MAX_TURNS = 500 + +def _lowercase(value: object) -> object: + """Enum-like env values are matched case-insensitively (``None`` is ``none``).""" + return value.strip().lower() if isinstance(value, str) else value + + _BASE_CONFIG = SettingsConfigDict( case_sensitive=False, populate_by_name=True, @@ -78,6 +84,11 @@ class LlmSettings(BaseSettings): alias="LLM_MAX_TOOL_CALLS_PER_TURN", ) + @field_validator("api_type", "reasoning_effort", mode="before") + @classmethod + def _normalize_case(cls, value: object) -> object: + return _lowercase(value) + class DedupeSettings(BaseSettings): model_config = _BASE_CONFIG @@ -95,6 +106,11 @@ class DedupeSettings(BaseSettings): repr=False, ) + @field_validator("reasoning_effort", mode="before") + @classmethod + def _normalize_case(cls, value: object) -> object: + return _lowercase(value) + class ContextSettings(BaseSettings): """Context-window management: per-tool-output caps and history compaction.""" diff --git a/strix/core/inputs.py b/strix/core/inputs.py index c0537386..bb33c5b9 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from typing import TYPE_CHECKING, Any from agents.model_settings import ModelSettings @@ -12,6 +13,7 @@ from strix.config.models import ( DEFAULT_MODEL_RETRY, OPENROUTER_ATTRIBUTION_HEADERS, bedrock_route_supports_prompt_caching, + chat_completions_accept_reasoning_effort, is_bedrock_route, is_claude_model, is_known_openai_bare_model, @@ -23,8 +25,11 @@ from strix.config.models import ( from strix.core.sessions import scrub_images_from_items +logger = logging.getLogger(__name__) + + if TYPE_CHECKING: - from strix.config.settings import ReasoningEffort + from strix.config.settings import ApiType, ReasoningEffort def _accepts_required_tool_choice(model_name: str | None) -> bool: @@ -253,7 +258,10 @@ def make_model_settings( prompt_cache: bool = True, extra_headers: dict[str, str] | None = None, has_tools: bool = True, + api_type: ApiType | None = None, ) -> ModelSettings: + """``api_type`` is the resolved SDK-native OpenAI route, when known; it decides + whether a reasoning model can be sent ``reasoning_effort`` at all.""" headers = _request_headers(model_name, extra_headers) model_settings = ModelSettings( parallel_tool_calls=False if has_tools else None, @@ -267,9 +275,16 @@ def make_model_settings( and reasoning_effort != "none" and model_supports_reasoning(model_name) ): - model_settings = model_settings.resolve( - _reasoning_settings(reasoning_effort), - ) + if _route_rejects_reasoning_effort(model_name, api_type): + logger.info( + "Omitting reasoning_effort=%s: %s does not accept it on chat completions", + reasoning_effort, + model_name, + ) + else: + model_settings = model_settings.resolve( + _reasoning_settings(reasoning_effort), + ) if force_required_tool_choice and _accepts_required_tool_choice(model_name): model_settings = model_settings.resolve(ModelSettings(tool_choice="required")) @@ -283,6 +298,14 @@ def make_model_settings( return model_settings +def _route_rejects_reasoning_effort(model_name: str, api_type: ApiType | None) -> bool: + """LiteLLM drops unsupported parameters itself on its own route; the + SDK-native chat completions route sends whatever it is given.""" + if api_type != "chat_completions" or routes_through_litellm(model_name): + return False + return not chat_completions_accept_reasoning_effort(model_name) + + def _request_headers( model_name: str, extra_headers: dict[str, str] | None ) -> dict[str, str] | None: diff --git a/strix/core/runner.py b/strix/core/runner.py index 7fdf2662..6192063d 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -22,6 +22,7 @@ from strix.config import load_settings from strix.config.models import ( StrixProvider, configure_sdk_model_defaults, + resolve_api_type, supports_strict_tool_schemas, uses_chat_completions_tool_schema, ) @@ -372,6 +373,7 @@ async def run_strix_scan( request_timeout=settings.llm.timeout, prompt_cache=settings.llm.prompt_cache, extra_headers=settings.llm.extra_headers, + api_type=resolve_api_type(resolved_model, settings), ) run_config = RunConfig( model=resolved_model, diff --git a/tests/test_inputs.py b/tests/test_inputs.py index febe8600..e4cdf5aa 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -430,3 +430,31 @@ def test_user_headers_override_openrouter_attribution() -> None: assert headers["X-Title"] == "Custom" assert headers["X-Tenant"] == "acme" assert headers["HTTP-Referer"] == "https://strix.ai" + + +def test_reasoning_effort_omitted_where_chat_completions_reject_it() -> None: + # OpenAI serves some reasoning models' reasoning_effort on the Responses API + # only; sending it on chat completions fails the request outright. + chat = make_model_settings( + "high", model_name="gpt-daybreak-blue-latest", api_type="chat_completions" + ) + assert chat.reasoning is None + responses = make_model_settings( + "high", model_name="gpt-daybreak-blue-latest", api_type="responses" + ) + assert responses.reasoning is not None + assert responses.reasoning.effort == "high" + + +def test_reasoning_effort_kept_on_chat_completions_where_supported() -> None: + for model in ("gpt-5.6-sol", "gpt-5", "openai/gpt-5.4"): + settings = make_model_settings("high", model_name=model, api_type="chat_completions") + assert settings.reasoning is not None, model + assert settings.reasoning.effort == "high" + + +def test_reasoning_effort_left_to_litellm_on_its_route() -> None: + settings = make_model_settings( + "high", model_name="litellm/gpt-daybreak-blue-latest", api_type="chat_completions" + ) + assert settings.reasoning is not None diff --git a/tests/test_models.py b/tests/test_models.py index 54f1bb76..e025f4cb 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -16,6 +16,7 @@ from strix.config.models import ( _TurnGuardModel, configure_sdk_model_defaults, request_timeout_extra_args, + resolve_api_type, routes_through_litellm, supports_strict_tool_schemas, uses_chat_completions_tool_schema, @@ -138,3 +139,66 @@ def test_api_type_overrides_the_api_base_route( while isinstance(model, _NonStreamingModel | _TurnGuardModel | RequestLoggingModel): model = model._inner assert isinstance(model, expected) + + +def _settings(monkeypatch: pytest.MonkeyPatch, model: str, api_base: str | None) -> Settings: + monkeypatch.setenv("STRIX_LLM", model) + monkeypatch.delenv("STRIX_API_TYPE", raising=False) + for name in ("LLM_API_BASE", "OPENAI_API_BASE", "OPENAI_BASE_URL"): + monkeypatch.delenv(name, raising=False) + if api_base is not None: + monkeypatch.setenv("LLM_API_BASE", api_base) + return Settings() + + +def test_resolve_api_type_without_base_url_is_responses(monkeypatch: pytest.MonkeyPatch) -> None: + assert resolve_api_type("gpt-5", _settings(monkeypatch, "gpt-5", None)) == "responses" + assert resolve_api_type("gpt-4o", _settings(monkeypatch, "gpt-4o", None)) == "responses" + + +def test_resolve_api_type_gateway_defaults_to_chat_completions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = _settings(monkeypatch, "gpt-5.6-sol", "https://gateway.example/v1") + assert resolve_api_type("gpt-5.6-sol", settings) == "chat_completions" + assert resolve_api_type("my-private-model", settings) == "chat_completions" + + +def test_resolve_api_type_responses_only_model_ignores_base_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A model LiteLLM lists on /v1/responses alone cannot be served by chat completions.""" + settings = _settings(monkeypatch, "gpt-daybreak-blue-latest", "https://gateway.example/v1") + assert resolve_api_type("gpt-daybreak-blue-latest", settings) == "responses" + assert resolve_api_type("openai/gpt-daybreak-blue-latest", settings) == "responses" + assert uses_chat_completions_tool_schema("gpt-daybreak-blue-latest", settings) is False + + +def test_resolve_api_type_openai_host_behind_base_url_is_responses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = _settings(monkeypatch, "gpt-5", "https://api.openai.com/v1") + assert resolve_api_type("gpt-5", settings) == "responses" + assert uses_chat_completions_tool_schema("gpt-5", settings) is False + + +def test_resolve_api_type_explicit_override_wins(monkeypatch: pytest.MonkeyPatch) -> None: + _settings(monkeypatch, "gpt-daybreak-blue-latest", "https://gateway.example/v1") + monkeypatch.setenv("STRIX_API_TYPE", "chat_completions") + assert resolve_api_type("gpt-daybreak-blue-latest", Settings()) == "chat_completions" + monkeypatch.setenv("STRIX_API_TYPE", "Responses") + assert resolve_api_type("gpt-5", Settings()) == "responses" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("None", "none"), ("HIGH", "high"), (" xhigh ", "xhigh"), ("Max", "max")], +) +def test_reasoning_effort_is_case_insensitive( + monkeypatch: pytest.MonkeyPatch, raw: str, expected: str +) -> None: + monkeypatch.setenv("STRIX_REASONING_EFFORT", raw) + monkeypatch.setenv("STRIX_DEDUPE_REASONING_EFFORT", raw) + settings = Settings() + assert settings.llm.reasoning_effort == expected + assert settings.dedupe.reasoning_effort == expected