From ec74334e74514a8cb67e78a4b574d5e82d1ab1da Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 3 May 2026 13:37:50 -0700 Subject: [PATCH] feat(providers): api_surface toggle + mistral medium reasoning fix (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): api_surface toggle + mistral medium reasoning fix Mistral medium open-weights served by vLLM expects reasoning_effort via the Responses API (`reasoning.effort`), not as a `chat_template_kwargs` entry on Chat Completions. The session was unconditionally injecting `{"reasoning_effort": ...}` into `chat_template_kwargs` for every openai-compatible request, which corrupted the prompt rendering for any backend whose chat template didn't consume that key (Mistral medium, Mistral cloud, Groq, OpenRouter). Changes: - Add `api_surface` ("chat" | "responses") to `ModelConfig.server_compat` and thread it through `create_provider` / `model_registry.get_provider`. `openai-compatible` defaults to Chat Completions; operators can flip individual aliases to Responses for endpoints that support it. - New `vllm-mistral-medium` profile that pre-fills api_surface=responses on Detect for known Mistral medium model ids. - Drop the unconditional `reasoning_effort` injection into `chat_template_kwargs`. Operators running gpt-oss-style local templates that consume `reasoning_effort` from the chat template now opt in via `server_compat.extra_body.chat_template_kwargs`. - New "API Surface" select in the Models admin tab; allowlist-validated server-side at create/update time; pre-filled by Detect via the profile suggestion. - Evict the cached provider singleton in `ModelRegistry.reload()` when api_surface changes (previously only cfg.provider triggered eviction). - Fix `_run_agent` fallback path to inherit the session's primary alias for capability and server_compat resolution; previously the fallback passed `alias=None`, which silently dropped per-model caps on the agent path. Tests: 5117 passed (-m "not live"); ruff + mypy clean. * fix(providers): don't auto-suggest Responses for Mistral medium vLLM's Responses API surface for Mistral medium open-weights doesn't wire up the Mistral tool-call parser as of vLLM 0.x — tool calls leak into the response as ``[TOOL_CALLS]{...}`` text instead of structured tool_calls. Chat Completions on the same engine handles tools cleanly via ``--tool-call-parser mistral``, and reasoning can be turned on via the vLLM CLI ``--reasoning-parser`` flag. Drop the auto-suggest mapping so Detect falls back to the generic ``vllm`` profile. Keep the ``vllm-mistral-medium`` profile definition in place so an operator who specifically wants per-request effort and accepts the tool-calling limitation can still pick "Responses API" manually in the admin UI. * fix(providers): address Copilot review on PR #469 - providers/__init__.py: drop the redundant *_responses_provider / *_chat_provider names; have create_provider use _openai_provider and _openai_compat_provider directly so they're not flagged as unused globals. - console/server.py: tighten _validate_api_surface to a strict equality match against the canonical {"chat", "responses"} set. The previous strip().lower() membership check accepted ' Responses '/'CHAT' but stored the raw string verbatim, which then failed to round-trip through the admin , so they're rejected even though they'd survive a + case-insensitive membership check.""" + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + registry = _make_registry(alias="local", model="m") + client = _make_client(storage, registry) + + for bad in (" responses ", "RESPONSES", "Chat"): + resp = client.post( + "/v1/api/admin/model-definitions", + json={ + "alias": "noncanon", + "model": "x", + "provider": "openai-compatible", + "base_url": "http://localhost:9000/v1", + "api_key": "sk-x", + "capabilities": {"server_compat": {"api_surface": bad}}, + }, + ) + assert resp.status_code == 400, f"{bad!r}: {resp.text}" + + +def test_create_accepts_valid_api_surface(storage: SQLiteBackend) -> None: + """Canonical 'chat' / 'responses' / unset are all accepted and persisted.""" + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + registry = _make_registry(alias="local", model="m") + client = _make_client(storage, registry) + + resp = client.post( + "/v1/api/admin/model-definitions", + json={ + "alias": "responses-alias", + "model": "x", + "provider": "openai-compatible", + "base_url": "http://localhost:9000/v1", + "api_key": "sk-x", + "capabilities": {"server_compat": {"api_surface": "responses"}}, + }, + ) + assert resp.status_code == 200, resp.text + assert registry.has_alias("responses-alias") + + +def test_update_rejects_invalid_api_surface(storage: SQLiteBackend) -> None: + """PUT path also gates the validation, so an admin can't smuggle a bad + value into an existing alias.""" + _seed_model_def(storage, definition_id="m1", alias="local", model="m") + registry = _make_registry(alias="local", model="m") + client = _make_client(storage, registry) + + resp = client.put( + "/v1/api/admin/model-definitions/m1", + json={"capabilities": {"server_compat": {"api_surface": "junk"}}}, + ) + assert resp.status_code == 400, resp.text + assert "api_surface" in resp.json()["error"] + + def test_delete_endpoint_refreshes_registry(storage: SQLiteBackend) -> None: """DELETE drops the alias from the in-process registry too — a coord session that tried to resolve the deleted alias would diff --git a/tests/test_model_registry.py b/tests/test_model_registry.py index a2af3c62..1ce5bac5 100644 --- a/tests/test_model_registry.py +++ b/tests/test_model_registry.py @@ -1126,24 +1126,57 @@ class TestSessionAgentModel: def _captured_effort(captured: dict[str, Any]) -> str | None: """Pull reasoning_effort out of provider-specific shapes. - openai-compatible servers receive it via extra_body.chat_template_kwargs; - commercial providers receive it as a top-level kwarg. + Chat Completions delivers it as a top-level ``reasoning_effort`` kwarg + (when the model's caps permit it). Operators who route reasoning_effort + through ``chat_template_kwargs`` (gpt-oss-style local templates) get + it inside ``extra_body.chat_template_kwargs``. """ + if "reasoning_effort" in captured: + return captured["reasoning_effort"] eb = captured.get("extra_body") or {} ctk = eb.get("chat_template_kwargs") or {} - return ctk.get("reasoning_effort") or captured.get("reasoning_effort") + return ctk.get("reasoning_effort") + + @staticmethod + def _effort_caps() -> dict[str, Any]: + """Capabilities that allow Chat-Completions reasoning_effort to flow.""" + return { + "reasoning_effort_values": [ + "minimal", + "low", + "medium", + "high", + "max", + ], + } def _three_model_registry(self, **kwargs: Any) -> ModelRegistry: + caps = self._effort_caps() return ModelRegistry( models={ "main": ModelConfig( - "main", "http://m/v1", "k", "main-model", provider="openai-compatible" + "main", + "http://m/v1", + "k", + "main-model", + provider="openai-compatible", + capabilities=dict(caps), ), "smart": ModelConfig( - "smart", "http://s/v1", "k", "smart-model", provider="openai-compatible" + "smart", + "http://s/v1", + "k", + "smart-model", + provider="openai-compatible", + capabilities=dict(caps), ), "fast": ModelConfig( - "fast", "http://f/v1", "k", "fast-model", provider="openai-compatible" + "fast", + "http://f/v1", + "k", + "fast-model", + provider="openai-compatible", + capabilities=dict(caps), ), }, default="main", @@ -1249,6 +1282,45 @@ class TestSessionAgentModel: session._run_agent([{"role": "user", "content": "x"}], label="plan", agent_alias="fast") assert captured["model"] == "fast-model" + def test_session_fallback_inherits_primary_alias_for_caps(self) -> None: + """When _run_agent has no registry agent route, it must fall back to + the session's primary alias for capability and server_compat lookup — + otherwise per-model caps (reasoning_effort_values, server_compat) get + silently dropped on the agent path.""" + reg = self._three_model_registry() # no agent_model / plan_model set + session = _make_session(registry=reg, model_alias="main") + # Probe what _run_agent passes to _provider_extra_params and + # _resolve_capabilities by recording the model_alias on each call. + captured_extra_alias: list[str | None] = [] + captured_resolve_alias: list[str | None] = [] + original_extra = session._provider_extra_params + original_resolve = session._resolve_capabilities + + def spy_extra(*args: Any, **kwargs: Any) -> Any: + captured_extra_alias.append(kwargs.get("model_alias")) + return original_extra(*args, **kwargs) + + def spy_resolve(*args: Any, **kwargs: Any) -> Any: + # _resolve_capabilities(provider, model, alias) + alias = args[2] if len(args) >= 3 else kwargs.get("alias") + captured_resolve_alias.append(alias) + return original_resolve(*args, **kwargs) + + session._provider_extra_params = spy_extra # type: ignore[method-assign] + session._resolve_capabilities = spy_resolve # type: ignore[method-assign] + + self._capture_on(session.client) # patch client.chat.completions.create + session._run_agent([{"role": "user", "content": "x"}], label="plan") + + assert captured_extra_alias and captured_extra_alias[-1] == "main", ( + f"agent fallback path did not inherit primary alias for extra_params: " + f"{captured_extra_alias!r}" + ) + assert captured_resolve_alias and captured_resolve_alias[-1] == "main", ( + f"agent fallback path did not inherit primary alias for caps: " + f"{captured_resolve_alias!r}" + ) + def test_invalid_alias_raises_in_run_agent(self) -> None: """Defence-in-depth: _prepare_* validates first, but _run_agent rejects unknown aliases too rather than silently falling back.""" diff --git a/tests/test_providers.py b/tests/test_providers.py index fee76fbf..c658ebbd 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1383,6 +1383,34 @@ class TestProviderFactory: p2 = create_provider("openai") assert p1 is p2 + def test_create_provider_compat_responses_surface(self) -> None: + """openai-compatible + api_surface=responses returns the Responses provider.""" + from turnstone.core.providers import OpenAIResponsesProvider, create_provider + + provider = create_provider("openai-compatible", api_surface="responses") + assert isinstance(provider, OpenAIResponsesProvider) + + def test_create_provider_compat_chat_surface_default(self) -> None: + """openai-compatible defaults to Chat Completions.""" + from turnstone.core.providers import create_provider + + for surface in (None, "", "chat"): + provider = create_provider("openai-compatible", api_surface=surface) + assert isinstance(provider, OpenAIChatCompletionsProvider) + + def test_create_provider_invalid_api_surface(self) -> None: + from turnstone.core.providers import create_provider + + with pytest.raises(ValueError, match="Unknown api_surface"): + create_provider("openai-compatible", api_surface="bogus") + + def test_create_provider_openai_ignores_api_surface(self) -> None: + """Cloud OpenAI is always Responses regardless of api_surface.""" + from turnstone.core.providers import OpenAIResponsesProvider, create_provider + + provider = create_provider("openai", api_surface="chat") + assert isinstance(provider, OpenAIResponsesProvider) + # -- Google provider ------------------------------------------------------- def test_create_provider_google(self) -> None: diff --git a/tests/test_server_compat.py b/tests/test_server_compat.py index 546b6e3b..c7193432 100644 --- a/tests/test_server_compat.py +++ b/tests/test_server_compat.py @@ -89,6 +89,27 @@ class TestSuggestProfile: p = suggest_profile("vllm", "Google/GEMMA-4-31B-IT") assert p["capabilities"]["thinking_mode"] == "manual" + def test_vllm_mistral_medium_not_auto_suggested(self) -> None: + """Mistral medium falls back to the generic vLLM profile. + + We don't auto-suggest the Responses surface for Mistral medium because + vLLM's Responses API tool-call parser isn't wired up for it yet — + operators who want per-request reasoning effort must pick "Responses + API" manually in the admin UI and accept the tool-calling limitation. + """ + p = suggest_profile("vllm", "mistralai/Mistral-Medium-3-Instruct") + assert p["server_compat"]["server_type"] == "vllm" + assert "api_surface" not in p["server_compat"] + assert "capabilities" not in p + + def test_vllm_mistral_medium_profile_still_available(self) -> None: + """The vllm-mistral-medium profile remains in _PROFILES so an operator + who explicitly opts in via the admin UI gets the Responses surface.""" + from turnstone.core.server_compat import _PROFILES + + assert "vllm-mistral-medium" in _PROFILES + assert _PROFILES["vllm-mistral-medium"]["server_compat"]["api_surface"] == "responses" + def test_holo_requires_holo2(self) -> None: """Short 'holo' prefix shouldn't false-match; 'holo2' should match.""" p_short = suggest_profile("vllm", "some-org/hologram-7b") @@ -110,32 +131,47 @@ class TestSuggestProfile: class TestMergeServerCompat: - def test_empty_compat_returns_base_only(self) -> None: + def test_empty_base_and_compat_is_empty(self) -> None: + """No base, no compat → no extra_body needed.""" + assert merge_server_compat(None, {}) == {} + assert merge_server_compat({}, {}) == {} + + def test_explicit_base_passes_through(self) -> None: + """Explicit chat_template_kwargs base is forwarded as-is.""" base = {"reasoning_effort": "medium"} result = merge_server_compat(base, {}) assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}} - def test_extra_body_merged_top_level(self) -> None: - base = {"reasoning_effort": "medium"} - compat = {"extra_body": {"skip_special_tokens": False}} - result = merge_server_compat(base, compat) - assert result["skip_special_tokens"] is False - assert "chat_template_kwargs" in result + def test_extra_body_merged_top_level_no_base(self) -> None: + """Server-level overrides forward without a chat_template_kwargs wrapper.""" + result = merge_server_compat(None, {"extra_body": {"skip_special_tokens": False}}) + assert result == {"skip_special_tokens": False} - def test_full_vllm_gemma_compat(self) -> None: - base = {"reasoning_effort": "medium"} + def test_full_vllm_gemma_compat_no_base(self) -> None: + """vLLM workaround forwards on its own.""" compat = { "server_type": "vllm", "extra_body": {"skip_special_tokens": False}, } - result = merge_server_compat(base, compat) + result = merge_server_compat(None, compat) + assert result == {"skip_special_tokens": False} + + def test_operator_chat_template_kwargs_only(self) -> None: + """Operator can set chat_template_kwargs explicitly without seeding the base.""" + compat = { + "extra_body": { + "chat_template_kwargs": {"reasoning_effort": "high"}, + "skip_special_tokens": False, + }, + } + result = merge_server_compat(None, compat) assert result == { - "chat_template_kwargs": {"reasoning_effort": "medium"}, + "chat_template_kwargs": {"reasoning_effort": "high"}, "skip_special_tokens": False, } - def test_extra_body_chat_template_kwargs_deep_merged(self) -> None: - """chat_template_kwargs in extra_body is deep-merged, operator wins.""" + def test_extra_body_chat_template_kwargs_deep_merged_with_base(self) -> None: + """Operator chat_template_kwargs deep-merges over the seeded base.""" base = {"reasoning_effort": "medium"} compat = { "extra_body": { @@ -144,17 +180,15 @@ class TestMergeServerCompat: }, } result = merge_server_compat(base, compat) - # Operator values win over base assert result["chat_template_kwargs"]["custom_flag"] is True + # Operator value wins over seeded base assert result["chat_template_kwargs"]["reasoning_effort"] == "high" assert result["skip_special_tokens"] is False def test_extra_body_chat_template_kwargs_non_dict_ignored(self) -> None: """Non-dict chat_template_kwargs in extra_body is safely ignored.""" - base = {"reasoning_effort": "medium"} compat = {"extra_body": {"chat_template_kwargs": "bad"}} - result = merge_server_compat(base, compat) - assert result["chat_template_kwargs"] == {"reasoning_effort": "medium"} + assert merge_server_compat(None, compat) == {} def test_base_not_mutated(self) -> None: base = {"reasoning_effort": "medium"} @@ -164,9 +198,7 @@ class TestMergeServerCompat: def test_non_dict_extra_body_ignored(self) -> None: """Gracefully handle malformed server_compat.""" - base = {"reasoning_effort": "medium"} - result = merge_server_compat(base, {"extra_body": 42}) - assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}} + assert merge_server_compat(None, {"extra_body": 42}) == {} # --------------------------------------------------------------------------- @@ -178,45 +210,58 @@ class TestEndToEndRequestShaping: """Compose both layers — session builds extra_params, provider applies thinking.""" def test_vllm_gemma_full_flow(self) -> None: - """Session merges server workarounds, provider adds thinking param.""" + """Session forwards server workarounds, provider adds thinking param.""" caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking") - base_ctk = {"reasoning_effort": "medium"} server_compat = { "server_type": "vllm", "extra_body": {"skip_special_tokens": False}, } - # Step 1: session merges - extra_params = merge_server_compat(base_ctk, server_compat) - # Step 2: provider finalises + # Step 1: session forwards (no auto-injection of reasoning_effort). + extra_params = merge_server_compat(None, server_compat) + # Step 2: provider injects thinking param into chat_template_kwargs. extra_body = dict(extra_params) OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps) assert extra_body == { - "chat_template_kwargs": { - "reasoning_effort": "medium", - "enable_thinking": True, - }, + "chat_template_kwargs": {"enable_thinking": True}, "skip_special_tokens": False, } def test_granite_thinking_key(self) -> None: """Granite uses 'thinking' instead of 'enable_thinking'.""" caps = ModelCapabilities(thinking_mode="manual", thinking_param="thinking") - extra_params = merge_server_compat({"reasoning_effort": "low"}, {}) + extra_params = merge_server_compat(None, {}) extra_body = dict(extra_params) OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps) - assert extra_body["chat_template_kwargs"]["thinking"] is True - assert "enable_thinking" not in extra_body["chat_template_kwargs"] + assert extra_body == {"chat_template_kwargs": {"thinking": True}} def test_non_thinking_model_no_injection(self) -> None: - """Non-thinking model gets no thinking params.""" + """Non-thinking model gets no chat_template_kwargs at all.""" caps = ModelCapabilities() # thinking_mode="none" - extra_params = merge_server_compat({"reasoning_effort": "medium"}, {}) + extra_params = merge_server_compat(None, {}) extra_body = dict(extra_params) OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps) - assert extra_body == {"chat_template_kwargs": {"reasoning_effort": "medium"}} + assert extra_body == {} + + def test_operator_reasoning_effort_passthrough(self) -> None: + """Operator-supplied reasoning_effort under chat_template_kwargs is preserved.""" + caps = ModelCapabilities(thinking_mode="manual", thinking_param="enable_thinking") + compat = { + "server_type": "vllm", + "extra_body": {"chat_template_kwargs": {"reasoning_effort": "high"}}, + } + extra_params = merge_server_compat(None, compat) + extra_body = dict(extra_params) + OpenAIChatCompletionsProvider._apply_thinking_mode(extra_body, caps) + + assert extra_body == { + "chat_template_kwargs": { + "reasoning_effort": "high", + "enable_thinking": True, + }, + } # --------------------------------------------------------------------------- diff --git a/tests/test_session.py b/tests/test_session.py index 8b8e09b4..996c379c 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1182,7 +1182,7 @@ class TestAgentOutputGuard: class TestProviderExtraParams: - """Tests for _provider_extra_params — local-only chat_template_kwargs.""" + """Tests for _provider_extra_params — server_compat passthrough only.""" def _session_with_provider(self, provider_name: str, tmp_db) -> ChatSession: from turnstone.core.providers import create_provider @@ -1191,68 +1191,36 @@ class TestProviderExtraParams: session._provider = create_provider(provider_name) return session - def test_openai_compatible_returns_chat_template_kwargs(self, tmp_db): + def test_openai_compatible_no_compat_returns_none(self, tmp_db): + """No server_compat → no extra_body needed (no auto-injection).""" session = self._session_with_provider("openai-compatible", tmp_db) - result = session._provider_extra_params() - assert result is not None - assert "chat_template_kwargs" in result - assert result["chat_template_kwargs"]["reasoning_effort"] == "medium" + assert session._provider_extra_params() is None - def test_openai_commercial_returns_none(self, tmp_db): + def test_openai_commercial_no_compat_returns_none(self, tmp_db): + """Cloud OpenAI without server_compat → None.""" session = self._session_with_provider("openai", tmp_db) - result = session._provider_extra_params() - assert result is None + assert session._provider_extra_params() is None def test_anthropic_returns_none(self, tmp_db): session = self._session_with_provider("anthropic", tmp_db) - result = session._provider_extra_params() - assert result is None + assert session._provider_extra_params() is None - def test_reasoning_effort_override(self, tmp_db): + def test_no_reasoning_effort_kwarg(self, tmp_db): + """reasoning_effort is not part of the surface; passing it should TypeError. + + Splatted via ``**kwargs`` so static analyzers (CodeQL "wrong-name + argument" / mypy) don't flag the call — the point of this test is the + runtime contract, not the static type. + """ + import pytest + + bad_kwargs = {"reasoning_effort": "high"} session = self._session_with_provider("openai-compatible", tmp_db) - result = session._provider_extra_params(reasoning_effort="high") - assert result is not None - assert result["chat_template_kwargs"]["reasoning_effort"] == "high" + with pytest.raises(TypeError): + session._provider_extra_params(**bad_kwargs) - def test_explicit_openai_provider_overrides_session(self, tmp_db): - """Passing an explicit commercial OpenAI provider returns None even - when the session's own provider is openai-compatible.""" - from turnstone.core.providers import create_provider - - session = self._session_with_provider("openai-compatible", tmp_db) - openai_prov = create_provider("openai") - result = session._provider_extra_params(provider=openai_prov) - assert result is None - - def test_server_compat_extra_body_merged(self, tmp_db): - """server_compat.extra_body workarounds are merged into extra_params.""" - from turnstone.core.model_registry import ModelConfig, ModelRegistry - - session = self._session_with_provider("openai-compatible", tmp_db) - cfg = ModelConfig( - alias="test", - base_url="http://localhost:8000/v1", - api_key="none", - model="google/gemma-4-31B-it", - server_compat={ - "extra_body": {"skip_special_tokens": False}, - }, - ) - session._registry = ModelRegistry(models={"test": cfg}, default="test") - session._model_alias = "test" - result = session._provider_extra_params() - assert result is not None - assert result["chat_template_kwargs"]["reasoning_effort"] == "medium" - assert result["skip_special_tokens"] is False - - def test_empty_server_compat_backwards_compatible(self, tmp_db): - """Empty server_compat produces same output as before.""" - session = self._session_with_provider("openai-compatible", tmp_db) - result = session._provider_extra_params() - assert result == {"chat_template_kwargs": {"reasoning_effort": "medium"}} - - def test_server_compat_with_reasoning_effort_override(self, tmp_db): - """reasoning_effort override works alongside server_compat.""" + def test_server_compat_extra_body_passes_through(self, tmp_db): + """server_compat.extra_body workarounds forward as extra_params.""" from turnstone.core.model_registry import ModelConfig, ModelRegistry session = self._session_with_provider("openai-compatible", tmp_db) @@ -1265,10 +1233,25 @@ class TestProviderExtraParams: ) session._registry = ModelRegistry(models={"test": cfg}, default="test") session._model_alias = "test" - result = session._provider_extra_params(reasoning_effort="high") - assert result is not None - assert result["chat_template_kwargs"]["reasoning_effort"] == "high" - assert result["skip_special_tokens"] is False + result = session._provider_extra_params() + assert result == {"skip_special_tokens": False} + + def test_operator_chat_template_kwargs_pass_through(self, tmp_db): + """Operator-set chat_template_kwargs (e.g. for gpt-oss) forwards verbatim.""" + from turnstone.core.model_registry import ModelConfig, ModelRegistry + + session = self._session_with_provider("openai-compatible", tmp_db) + cfg = ModelConfig( + alias="test", + base_url="http://localhost:8000/v1", + api_key="none", + model="openai/gpt-oss-120b", + server_compat={"extra_body": {"chat_template_kwargs": {"reasoning_effort": "high"}}}, + ) + session._registry = ModelRegistry(models={"test": cfg}, default="test") + session._model_alias = "test" + result = session._provider_extra_params() + assert result == {"chat_template_kwargs": {"reasoning_effort": "high"}} def test_model_alias_resolves_target_compat(self, tmp_db): """model_alias parameter selects compat from the target, not the primary.""" @@ -1297,14 +1280,9 @@ class TestProviderExtraParams: session._model_alias = "primary" # Primary alias → gets Gemma workaround - result_primary = session._provider_extra_params() - assert result_primary is not None - assert result_primary["skip_special_tokens"] is False - - # Fallback alias → no compat, just base kwargs - result_fallback = session._provider_extra_params(model_alias="fallback") - assert result_fallback == {"chat_template_kwargs": {"reasoning_effort": "medium"}} - assert "skip_special_tokens" not in result_fallback + assert session._provider_extra_params() == {"skip_special_tokens": False} + # Fallback alias → no compat at all + assert session._provider_extra_params(model_alias="fallback") is None class TestSafePrepareTool: diff --git a/turnstone/console/server.py b/turnstone/console/server.py index d52646d6..7515441c 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -8090,6 +8090,33 @@ _MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "googl _REASONING_EFFORT_CHOICES = frozenset( {"", "none", "minimal", "low", "medium", "high", "xhigh", "max"} ) +# Keep in sync with turnstone.core.providers._VALID_API_SURFACES. +_API_SURFACE_CHOICES = frozenset({"chat", "responses"}) + + +def _validate_api_surface(caps: Any) -> str | None: + """Return an error message if ``caps["server_compat"]["api_surface"]`` is invalid. + + Strict equality match (no strip/lower normalisation): the persisted value + is bound directly to the admin `` + +