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:
committed by
Xubin Ren
parent
d36117de7a
commit
c2c47f7a03
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user