diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 7ce749f594..9add7c84ae 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -169,7 +169,8 @@ _IMAGE_CORRUPT_PATTERNS = ( _MULTIMODAL_TOOL_CONTENT_PATTERNS = ( "text is not set", "tool message content must be a string", "tool content must be a string", "tool message must be a string", "expected string, got list", "expected string, got array", - "tool_call.content must be string", + "tool_call.content must be string", "tool.content.str", "tool.content", + "input should be a valid string", ) # Local-inference memory/resource-ceiling rejections (oMLX/MLX memory guard, @@ -763,6 +764,7 @@ def _classify_400(c: _Ctx) -> Verdict: _STATUS_HANDLERS: Dict[int, Callable[[_Ctx], Verdict]] = { 400: _classify_400, 401: lambda c: _V_AUTH_ROTATE, 402: lambda c: _classify_402(c.msg, dict), 403: _status_403, 404: _status_404, 408: lambda c: _V_TIMEOUT, 413: lambda c: _V_PAYLOAD_TOO_LARGE, + 422: lambda c: _first_match(c.msg, _IMAGE_TOOL_RULES) or _V_FORMAT_ERROR, 429: _status_429, 500: _status_5xx, 502: _status_5xx, 503: lambda c: _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_OVERLOADED, 529: lambda c: _first_match(c.msg, _OVERFLOW_AS_5XX_RULES) or _V_OVERLOADED, diff --git a/plugins/model-providers/opencode-zen/__init__.py b/plugins/model-providers/opencode-zen/__init__.py index 1a3be51829..a2ee868dc3 100644 --- a/plugins/model-providers/opencode-zen/__init__.py +++ b/plugins/model-providers/opencode-zen/__init__.py @@ -115,10 +115,11 @@ opencode_go = OpenCodeGoProfile( name="opencode-go", aliases=("opencode_go", "go", "opencode-go-sub"), env_vars=("OPENCODE_GO_API_KEY",), base_url="https://opencode.ai/zen/go/v1", default_headers=dict(_ATTRIBUTION_HEADERS), default_aux_model="glm-5", - # opencode-go proxies to Xiaomi MiMo for mimo-* models; MiMo rejects - # list-type tool content with "text is not set" (400). The direct - # xiaomi profile already sets supports_vision_tool_messages=False; - # propagate the same safety here for the relay path. + # The Go relay's upstream validates tool content as a strict string: list-type tool + # content (native vision embeds) 422s with ``messages.N.tool.content.str Input should + # be a valid string`` (Console Go, #104731) or 400s ``text is not set`` (MiMo, #47026), + # and the rejected row stays in history so every later call dies too. Images in user + # messages are fine, so vision itself keeps working via the text-summary downgrade. supports_vision_tool_messages=False, ) diff --git a/tests/plugins/model_providers/test_opencode_go_profile.py b/tests/plugins/model_providers/test_opencode_go_profile.py index 54fae9b15f..2accf6bf3d 100644 --- a/tests/plugins/model_providers/test_opencode_go_profile.py +++ b/tests/plugins/model_providers/test_opencode_go_profile.py @@ -253,3 +253,12 @@ class TestOpenCodeGoFullKwargsIntegration: ) assert "extra_body" not in kwargs assert kwargs["reasoning_effort"] == "high" + + +class TestOpenCodeGoVisionToolMessages: + """OpenCode Go provider profile declares supports_vision_tool_messages=False (#104731).""" + + def test_supports_vision_true_and_tool_messages_false(self, opencode_go_profile): + assert opencode_go_profile.supports_vision is True + assert opencode_go_profile.supports_vision_tool_messages is False + diff --git a/tests/run_agent/test_multimodal_tool_content_recovery.py b/tests/run_agent/test_multimodal_tool_content_recovery.py index e2c91e3fb2..7119310e80 100644 --- a/tests/run_agent/test_multimodal_tool_content_recovery.py +++ b/tests/run_agent/test_multimodal_tool_content_recovery.py @@ -196,3 +196,42 @@ class TestRecoveryEndToEndClassification: ) result = classify_api_error(err, provider="alibaba", model="qwen3.5-plus") assert result.reason == FailoverReason.multimodal_tool_content_unsupported + + def test_opencode_go_422_console_go_classifies(self): + """Regression test for #104731: Console Go relay HTTP 422 on list-type tool content.""" + err = _FakeApiError( + status_code=422, + message=( + "Error code: 422 - {'error': {'param': 'messages.67.tool.content.str', " + "'type': 'invalid_request_error', 'message': 'Error from provider (Console Go): " + "Upstream request failed: [invalid_request_error] Input should be a valid string'}}" + ), + ) + result = classify_api_error(err, provider="opencode-go", model="deepseek-v4-flash-vision-exp") + assert result.reason == FailoverReason.multimodal_tool_content_unsupported + assert result.retryable is True + + +class TestOpenCodeGoProactiveToolResultDowngrade: + def _multimodal_result(self, png_b64: str = "iVBORw0KGgoAAAA"): + return { + "_multimodal": True, + "content": [ + {"type": "text", "text": "detected 2 objects"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{png_b64}"}}, + ], + "text_summary": "detected 2 objects", + "meta": {"png_bytes": 1024}, + } + + def test_returns_text_summary_for_opencode_go_proactively(self, monkeypatch): + """OpenCode Go rejects list-type tool content, so _tool_result_content_for_active_model + should proactively downgrade to a text summary (#104731).""" + agent = _make_agent(provider="opencode-go", model="deepseek-v4-flash-vision-exp") + agent._no_list_tool_content_models = set() + monkeypatch.setattr(agent, "_model_supports_vision", lambda: True) + out = agent._tool_result_content_for_active_model("vision_analyze", self._multimodal_result()) + assert isinstance(out, str) + assert "data:image" not in out + assert "image_url" not in out +