mirror of
https://github.com/usestrix/strix.git
synced 2026-10-05 02:41:38 +00:00
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:
parent
007ed1a94e
commit
6ab123484d
6 changed files with 215 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue