From 7e9cf3c583eae9b0c69774bd60cca0638a90cd04 Mon Sep 17 00:00:00 2001 From: jonathan bechtel Date: Wed, 10 Jun 2026 20:22:28 -0400 Subject: [PATCH] fix(adapter): surface OpenRouter error body instead of KeyError('choices') OpenRouter returns error objects with HTTP 200 for some failures (context length exceeded, provider moderation). The adapter crashed with an opaque KeyError('choices'); now it raises RuntimeError with the provider's actual error message. Found live when gpt-oss-120b (131k context) hit the ~184k-token operations tasks. Co-Authored-By: Claude Fable 5 --- runner/adapters/openrouter_adapter.py | 9 +++++++++ tests/unit/test_openrouter_adapter.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/runner/adapters/openrouter_adapter.py b/runner/adapters/openrouter_adapter.py index 2487a08..bc64b5b 100644 --- a/runner/adapters/openrouter_adapter.py +++ b/runner/adapters/openrouter_adapter.py @@ -557,6 +557,15 @@ def run(self, task: dict[str, Any], run_index: int = 0) -> dict[str, Any]: timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") body: dict[str, Any] = response.json() + # OpenRouter can return an error object with HTTP 200 (e.g. context + # length exceeded, provider moderation) — surface its message instead + # of crashing with KeyError('choices'). + if "choices" not in body or not body["choices"]: + err = body.get("error") or {} + raise RuntimeError( + f"OpenRouter returned no choices for model {self._model!r}: " + f"{err.get('message') or json.dumps(body)[:300]}" + ) choice = body["choices"][0] raw_text: str = choice["message"]["content"] or "" finish_reason: str | None = choice.get("finish_reason") diff --git a/tests/unit/test_openrouter_adapter.py b/tests/unit/test_openrouter_adapter.py index 1eee7d8..f61fb9f 100644 --- a/tests/unit/test_openrouter_adapter.py +++ b/tests/unit/test_openrouter_adapter.py @@ -525,6 +525,29 @@ def test_non_200_raises_runtime_error(self) -> None: status_code=429, ) + def test_error_body_with_200_surfaces_provider_message(self) -> None: + """An HTTP-200 body without choices must raise with the provider's error message. + + OpenRouter returns error objects with HTTP 200 for some failures + (e.g. context length exceeded) — these must not crash with + KeyError('choices'). + """ + with pytest.raises(RuntimeError, match="maximum context length"): + self._run_with_mock( + mock_body={ + "error": { + "message": "This endpoint's maximum context length is 131072 tokens.", + "code": 400, + } + }, + status_code=200, + ) + + def test_empty_choices_list_raises_runtime_error(self) -> None: + """An HTTP-200 body with an empty choices list must raise RuntimeError.""" + with pytest.raises(RuntimeError, match="no choices"): + self._run_with_mock(mock_body={"choices": []}, status_code=200) + def test_missing_api_key_raises_environment_error(self, monkeypatch: Any) -> None: """Missing API key (no env var, no constructor arg) must raise EnvironmentError.""" monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)