From 27212675218a7be144d55b0c24e161cdf6b670f4 Mon Sep 17 00:00:00 2001 From: Stuart Gano Date: Thu, 9 Jul 2026 11:25:31 -0700 Subject: [PATCH] Allow explicitly disabling the web_search MCP with web_search_model: false codex_models discovery only confirms an endpoint speaks the Responses API wire protocol, not that it has real OpenAI backing for server-side tools. A self-hosted model served through Databricks' Open Responses API wrapper (e.g. gpt-oss) matches the wire-protocol filter but has no such backing, so web_search calls to it 400 with "No OpenAI Response API backing." When that's the only discovered endpoint, there was previously no way to opt out short of manually removing the MCP registration after every `ucode configure` run. - `_resolve_web_search_model` treats `web_search_model: False` as an explicit opt-out, short-circuiting before the codex_models fallback. - `write_tool_config` now unregisters any stale web_search MCP entry when no model resolves, instead of only ever adding one. --- src/ucode/agents/claude.py | 21 +++++++++++++++++++-- tests/test_agent_claude.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 422e763..8af1343 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -51,9 +51,21 @@ def is_update_available() -> tuple[str, str] | None: def _resolve_web_search_model(state: dict) -> str | None: """Pick the model the web_search MCP server should call. Prefers an explicit override in state, otherwise the first endpoint discovered as - Responses-API-capable. Returns None if no GPT endpoint is available — - callers should skip the MCP wiring in that case.""" + Responses-API-capable. Returns None if no GPT endpoint is available, or + if `web_search_model` is explicitly set to `False` to opt out — callers + should skip the MCP wiring in that case. + + The `False` opt-out exists because Responses-API-shaped discovery + (`codex_models`) can only tell that an endpoint speaks the wire protocol, + not that it has real OpenAI backing for server-side tools like + `web_search`. A self-hosted model served through Databricks' Open + Responses API wrapper (e.g. gpt-oss) matches the wire-protocol filter but + has no such backing, so calling it 400s with "No OpenAI Response API + backing." When that's the only candidate, set `web_search_model: false` + in ucode's state to stop auto-discovery from picking it.""" override = state.get("web_search_model") + if override is False: + return None if isinstance(override, str) and override.strip(): return override.strip() codex_models = state.get("codex_models") or [] @@ -335,6 +347,11 @@ def write_tool_config( if web_search_model: _register_web_search_mcp(state["workspace"], web_search_model, state.get("profile")) + else: + # Clean up a stale registration from a prior run — e.g. the workspace's + # only codex model was removed, or `web_search_model` was just set to + # `False` to opt out of a previously-registered broken endpoint. + _unregister_web_search_mcp() state = mark_tool_managed(state, "claude", managed_keys) save_state(state) diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index b2d3a27..206bdda 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -258,6 +258,17 @@ def test_override_wins_over_codex_models(self): state = {"web_search_model": "winner", "codex_models": ["loser"]} assert claude._resolve_web_search_model(state) == "winner" + def test_explicit_false_disables_regardless_of_codex_models(self): + # A self-hosted model (e.g. gpt-oss) can be the only discovered codex + # model yet have no real OpenAI backing for the web_search tool. An + # explicit `False` opts out instead of silently picking it. + state = {"web_search_model": False, "codex_models": ["mirion_ai_bronze"]} + assert claude._resolve_web_search_model(state) is None + + def test_explicit_false_beats_string_codex_model(self): + state = {"web_search_model": False, "codex_models": ["m1"]} + assert claude._resolve_web_search_model(state) is None + class TestClaudeDefaultModel: def test_prefers_opus(self): @@ -308,6 +319,11 @@ def _common_patches(self, monkeypatch, calls): "_register_web_search_mcp", lambda ws, model, profile=None: calls.append(("register", ws, model)), ) + monkeypatch.setattr( + claude, + "_unregister_web_search_mcp", + lambda: calls.append(("unregister",)), + ) def test_registers_mcp_when_codex_model_available(self, monkeypatch): calls: list = [] @@ -321,7 +337,10 @@ def test_skips_registration_without_codex_model(self, monkeypatch): self._common_patches(monkeypatch, calls) state = {"workspace": WS, "codex_models": []} claude.write_tool_config(state, "databricks-claude-sonnet-4") - assert calls == [] + # No candidate model exists, so any stale registration from a prior + # run (e.g. the workspace's only GPT endpoint was decommissioned) + # gets cleaned up rather than left dangling. + assert calls == [("unregister",)] def test_explicit_override_used_over_codex_models(self, monkeypatch): calls: list = [] @@ -334,6 +353,23 @@ def test_explicit_override_used_over_codex_models(self, monkeypatch): claude.write_tool_config(state, "databricks-claude-sonnet-4") assert calls == [("register", WS, "explicit-model")] + def test_explicit_false_unregisters_instead_of_registering(self, monkeypatch): + # Regression: a workspace whose only Responses-API-shaped endpoint is + # a self-hosted model (e.g. gpt-oss served through Databricks' Open + # Responses API wrapper) has no real OpenAI backing for the + # web_search tool, so calling it 400s with "No OpenAI Response API + # backing." `web_search_model: false` opts out and any previously + # registered (broken) entry is removed. + calls: list = [] + self._common_patches(monkeypatch, calls) + state = { + "workspace": WS, + "web_search_model": False, + "codex_models": ["mirion_ai_bronze"], + } + claude.write_tool_config(state, "databricks-claude-sonnet-4") + assert calls == [("unregister",)] + class TestRegisterWebSearchMcp: def test_clears_existing_then_adds(self, monkeypatch):