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: