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.
This commit is contained in:
Ahmed Allam 2026-10-02 12:50:06 +00:00 • committed by Ahmed Allam
parent 007ed1a94e
commit 6ab123484d
6 changed files with 215 additions and 18 deletions

View file

@ -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:

View file

@ -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."""

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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