From c2c47f7a03bdd5634237ae53b0a81023a4b0a63e Mon Sep 17 00:00:00 2001 From: nanobot-contributor Date: Thu, 11 Jun 2026 04:38:58 +0800 Subject: [PATCH] fix(fallback): treat empty API choices as fallbackable error When the primary model (e.g. DeepSeek during peak hours) returns an empty choices response with HTTP 200, the error carries no status code or structured error metadata. The existing _FALLBACK_ERROR_TOKENS had no matching token, so _should_fallback() returned False and fallback models were never tried. Changes: - Add 'empty' token to _FALLBACK_ERROR_TOKENS so 'Error: API returned empty choices.' text matches the fallback path - Set error_kind='empty' in openai_compat_provider when returning the empty-choices error, making the classification explicit - Add test coverage for both text-only and error_kind matching paths Fixes: glebov reported primary never falls back when DeepSeek returns empty responses --- nanobot/providers/fallback_provider.py | 1 + nanobot/providers/openai_compat_provider.py | 12 ++++- tests/agent/test_runner_fallback.py | 50 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/fallback_provider.py b/nanobot/providers/fallback_provider.py index 7e906062..77b668b3 100644 --- a/nanobot/providers/fallback_provider.py +++ b/nanobot/providers/fallback_provider.py @@ -42,6 +42,7 @@ _FALLBACK_ERROR_TOKENS = ( "timeout", "timed out", "connection", + "empty", # API returned empty choices (e.g. DeepSeek peak hours), transient "insufficient_quota", "insufficient quota", "quota_exceeded", diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index cccb9391..91a5b667 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -1094,7 +1094,11 @@ class OpenAICompatProvider(LLMProvider): finish_reason=str(response_map.get("finish_reason") or "stop"), usage=self._extract_usage(response_map), ) - return LLMResponse(content="Error: API returned empty choices.", finish_reason="error") + return LLMResponse( + content="Error: API returned empty choices.", + finish_reason="error", + error_kind="empty", + ) choice0 = self._maybe_mapping(choices[0]) or {} msg0 = self._maybe_mapping(choice0.get("message")) or {} @@ -1151,7 +1155,11 @@ class OpenAICompatProvider(LLMProvider): ) if not response.choices: - return LLMResponse(content="Error: API returned empty choices.", finish_reason="error") + return LLMResponse( + content="Error: API returned empty choices.", + finish_reason="error", + error_kind="empty", + ) choice = response.choices[0] msg = choice.message diff --git a/tests/agent/test_runner_fallback.py b/tests/agent/test_runner_fallback.py index 632d3920..e610176c 100644 --- a/tests/agent/test_runner_fallback.py +++ b/tests/agent/test_runner_fallback.py @@ -368,6 +368,56 @@ class TestFallbackOnStreamStalledAfterContent: assert recoveries == ["recover"] +class TestFailoverOnEmptyChoices: + """Fallback should trigger when API returns empty choices (no error metadata).""" + + @pytest.mark.asyncio + async def test_empty_choices_text_fallback(self) -> None: + """_should_fallback should return True for 'API returned empty choices'.""" + from nanobot.providers.fallback_provider import FallbackProvider + + response = _make_response( + "Error: API returned empty choices.", + finish_reason="error", + error_kind="empty", + ) + # error_kind="empty" matches _FALLBACK_ERROR_KINDS via kind check + assert FallbackProvider._should_fallback(response) + + @pytest.mark.asyncio + async def test_empty_choices_no_error_kind_text_fallback(self) -> None: + """_should_fallback should also match via text token when error_kind is None.""" + from nanobot.providers.fallback_provider import FallbackProvider + + response = _make_response( + "Error: API returned empty choices.", + finish_reason="error", + # error_kind=None, no status — pure text matching + ) + # "empty" token in _FALLBACK_ERROR_TOKENS matches via text fallback + assert FallbackProvider._should_fallback(response) + + @pytest.mark.asyncio + async def test_empty_choices_triggers_failover(self) -> None: + """End-to-end: empty choices on primary triggers fallback.""" + primary = _FakeProvider( + "primary", + _make_response("Error: API returned empty choices.", finish_reason="error"), + ) + fallback = _FakeProvider("fallback", _make_response("fallback ok")) + factory = MagicMock(return_value=fallback) + fb = FallbackProvider( + primary=primary, + fallback_presets=[_fallback("fallback-a")], + provider_factory=factory, + ) + + result = await fb.chat(messages=[{"role": "user", "content": "hi"}]) + assert result.content == "fallback ok" + assert result.finish_reason == "stop" + factory.assert_called_once() + + class TestFailoverOnTransientError: @pytest.mark.asyncio async def test_rate_limit(self) -> None: