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