Compare commits

...
Author SHA1 Message Date
Ahmed Allam 9077714070 chore(inputs): drop unused logger 2026-10-02 15:15:03 +00:00
Ahmed Allam 17bcc954fb fix(inputs): send reasoning_effort as configured; no route-specific handling 2026-10-02 15:14:18 +00:00
Ahmed Allam 92373a55bf fix(inputs): send reasoning_effort=none explicitly on chat completions; hint at the Responses API when tools+effort are rejected 2026-10-02 15:02:46 +00:00
Ahmed Allam e39cc0d12a fix(runner): pick the SDK route from the resolved model override
configure_sdk_model_defaults only sees STRIX_LLM; a model= override to
run_strix_scan re-applies the route for the model that actually runs.
2026-10-02 13:00:39 +00:00
Ahmed Allam f678e786cc 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.
2026-10-02 12:50:06 +00:00
6 changed files with 172 additions and 19 deletions

No files matched your search

+62 -14
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,54 @@ 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"
set_default_openai_api(api_type)
configure_sdk_api_route(llm.model or "", settings)
_configure_extra_headers(llm)
def configure_sdk_api_route(model_name: str, settings: Settings) -> None:
"""Point SDK-native OpenAI requests for ``model_name`` at Responses or chat completions."""
api_type = resolve_api_type(model_name, settings)
logger.info("OpenAI API route for %s: %s", model_name, api_type)
set_default_openai_api(api_type)
_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 _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
if not model_name:
return
@@ -859,10 +899,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 +917,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:
+17 -1
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."""
+1 -3
View File
@@ -267,9 +267,7 @@ def make_model_settings(
and reasoning_effort != "none"
and model_supports_reasoning(model_name)
):
model_settings = model_settings.resolve(
_reasoning_settings(reasoning_effort),
)
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"))
+6 -1
View File
@@ -18,9 +18,10 @@ from openai import RateLimitError
from strix.agents.factory import build_strix_agent, make_child_factory
from strix.agents.prompt import render_scope_prompt, render_system_prompt
from strix.config import load_settings
from strix.config import codex, load_settings
from strix.config.models import (
StrixProvider,
configure_sdk_api_route,
configure_sdk_model_defaults,
supports_strict_tool_schemas,
uses_chat_completions_tool_schema,
@@ -257,6 +258,10 @@ async def run_strix_scan(
raise RuntimeError(
"No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().",
)
if resolved_model != (settings.llm.model or "").strip() and not codex.subscription_model(
resolved_model
):
configure_sdk_api_route(resolved_model, settings)
logger.info("LLM model resolved: %s", resolved_model)
chat_completions_tools = uses_chat_completions_tool_schema(resolved_model, settings)
strict_tool_schemas = supports_strict_tool_schemas(resolved_model)
+7
View File
@@ -430,3 +430,10 @@ 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_sent_as_configured_and_none_omitted() -> None:
assert make_model_settings("none", model_name="gpt-5.6-sol").reasoning is None
settings = make_model_settings("high", model_name="gpt-daybreak-blue-latest")
assert settings.reasoning is not None
assert settings.reasoning.effort == "high"
+79
View File
@@ -10,12 +10,14 @@ from agents.models import _openai_shared
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.models.openai_responses import OpenAIResponsesModel
from strix.config import models
from strix.config.models import (
StrixProvider,
_NonStreamingModel,
_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 +140,80 @@ 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
def test_configure_sdk_api_route_follows_the_given_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``model=`` override picks its own route, not ``STRIX_LLM``'s."""
routes: list[str] = []
monkeypatch.setattr(models, "set_default_openai_api", routes.append)
settings = _settings(monkeypatch, "gpt-5", "https://gateway.example/v1")
models.configure_sdk_api_route("gpt-5", settings)
models.configure_sdk_api_route("gpt-daybreak-blue-latest", settings)
assert routes == ["chat_completions", "responses"]