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
This commit is contained in:
nanobot-contributor
2026-06-19 14:59:44 +08:00
committed by Xubin Ren
parent d36117de7a
commit c2c47f7a03
3 changed files with 61 additions and 2 deletions
+1
View File
@@ -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",
+10 -2
View File
@@ -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
+50
View File
@@ -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: