mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
DeepSeek's new Responses endpoint (deepseek-v4-flash) intermittently rejects valid request bodies with serde deserialization errors such as 'input: invalid type: string ..., expected a sequence'. These were not classified as compatibility errors, so affected conversations died instead of falling back to Chat Completions. The wire format is correct (input serializes as a list), so this is a server-side Responses compatibility issue; Chat Completions is strictly more permissive, making fallback safe. Extend the fallback classifier to recognize serde body-parsing markers. Repeated failures still trip the existing circuit breaker.
205 lines
7.2 KiB
Python
205 lines
7.2 KiB
Python
"""Tests for Responses API circuit breaker in OpenAICompatProvider."""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from nanobot.providers.base import ProviderCallContext
|
|
from nanobot.providers.openai_compat_provider import (
|
|
_RESPONSES_FAILURE_THRESHOLD,
|
|
_RESPONSES_PROBE_INTERVAL_S,
|
|
OpenAICompatProvider,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def provider():
|
|
"""A direct-OpenAI provider with Responses API support."""
|
|
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
|
|
p.default_model = "gpt-5"
|
|
p._spec = type("Spec", (), {"name": "openai"})()
|
|
p._effective_base = "https://api.openai.com/v1"
|
|
p._api_type = "auto"
|
|
p._responses_failures = {}
|
|
p._responses_tripped_at = {}
|
|
return p
|
|
|
|
|
|
def test_responses_api_available_by_default(provider):
|
|
assert provider._should_use_responses_api("gpt-5", None) is True
|
|
|
|
|
|
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
|
provider._spec = type("Spec", (), {
|
|
"name": "deepseek",
|
|
"responses_models": ("deepseek-v4-flash",),
|
|
"strip_model_prefix": False,
|
|
"strip_model_prefixes": (),
|
|
})()
|
|
provider._effective_base = "https://api.deepseek.com"
|
|
provider.default_model = "deepseek-v4-flash"
|
|
|
|
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
|
|
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
|
|
|
|
|
|
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
|
provider._spec = type("Spec", (), {
|
|
"name": "deepseek",
|
|
"responses_models": ("deepseek-v4-flash",),
|
|
"strip_model_prefix": False,
|
|
"strip_model_prefixes": (),
|
|
})()
|
|
provider._effective_base = "https://api.deepseek.com"
|
|
|
|
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
|
|
|
|
|
|
def test_direct_openai_enables_server_compaction(provider):
|
|
provider._extra_body = {}
|
|
|
|
body = provider._build_responses_body(
|
|
messages=[{"role": "user", "content": "hello"}],
|
|
tools=None,
|
|
model="gpt-5.6",
|
|
max_tokens=30_000,
|
|
temperature=0.1,
|
|
reasoning_effort="high",
|
|
tool_choice=None,
|
|
provider_context=ProviderCallContext(context_window_tokens=100_000),
|
|
)
|
|
|
|
assert body["context_management"] == [{
|
|
"type": "compaction",
|
|
"compact_threshold": 70_000,
|
|
}]
|
|
|
|
|
|
def test_api_type_chat_completions_disables_responses(provider):
|
|
provider._api_type = "chat_completions"
|
|
assert provider._should_use_responses_api("gpt-5", None) is False
|
|
|
|
|
|
def test_api_type_responses_forces_responses_for_openai(provider):
|
|
provider.default_model = "gpt-4o"
|
|
provider._api_type = "responses"
|
|
assert provider._should_use_responses_api("gpt-4o", None) is True
|
|
|
|
|
|
def test_api_type_responses_ignores_circuit_breaker(provider):
|
|
provider.default_model = "gpt-4o"
|
|
provider._api_type = "responses"
|
|
provider._responses_failures = {"gpt-4o|gpt-4o|": _RESPONSES_FAILURE_THRESHOLD}
|
|
provider._responses_tripped_at = {"gpt-4o|gpt-4o|": 0.0}
|
|
|
|
assert provider._should_use_responses_api("gpt-4o", None) is True
|
|
|
|
|
|
def test_api_type_responses_does_not_force_non_openai(provider):
|
|
provider._spec = type("Spec", (), {"name": "custom"})()
|
|
provider._api_type = "responses"
|
|
|
|
assert provider._should_use_responses_api("gpt-4o", None) is False
|
|
|
|
|
|
def test_circuit_opens_after_threshold(provider):
|
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
|
provider._record_responses_failure("gpt-5", None)
|
|
assert provider._should_use_responses_api("gpt-5", None) is False
|
|
|
|
|
|
def test_circuit_does_not_affect_other_models(provider):
|
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
|
provider._record_responses_failure("gpt-5", None)
|
|
assert provider._should_use_responses_api("o4-mini", None) is True
|
|
|
|
|
|
def test_success_resets_circuit(provider):
|
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
|
provider._record_responses_failure("gpt-5", None)
|
|
assert provider._should_use_responses_api("gpt-5", None) is False
|
|
provider._record_responses_success("gpt-5", None)
|
|
assert provider._should_use_responses_api("gpt-5", None) is True
|
|
|
|
|
|
def test_probe_after_interval(provider, monkeypatch):
|
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
|
provider._record_responses_failure("gpt-5", None)
|
|
assert provider._should_use_responses_api("gpt-5", None) is False
|
|
|
|
# Fast-forward past the probe interval
|
|
key = "gpt-5:"
|
|
provider._responses_tripped_at[key] = time.monotonic() - _RESPONSES_PROBE_INTERVAL_S - 1
|
|
assert provider._should_use_responses_api("gpt-5", None) is True
|
|
|
|
|
|
def test_below_threshold_still_allows(provider):
|
|
provider._record_responses_failure("gpt-5", None)
|
|
provider._record_responses_failure("gpt-5", None)
|
|
assert provider._should_use_responses_api("gpt-5", None) is True
|
|
|
|
|
|
def test_reasoning_effort_keyed_separately(provider):
|
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
|
provider._record_responses_failure("o3", "high")
|
|
assert provider._should_use_responses_api("o3", "high") is False
|
|
assert provider._should_use_responses_api("o3", "low") is True
|
|
|
|
|
|
def test_reasoning_effort_key_is_case_insensitive(provider):
|
|
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
|
|
provider._record_responses_failure("o3", "High")
|
|
assert provider._should_use_responses_api("o3", "high") is False
|
|
|
|
|
|
# ======================================================================
|
|
# _should_fallback_from_responses_error
|
|
# ======================================================================
|
|
|
|
|
|
class _FakeAPIError(Exception):
|
|
def __init__(self, status_code, body):
|
|
super().__init__(str(body))
|
|
self.status_code = status_code
|
|
self.body = body
|
|
self.response = None
|
|
|
|
|
|
def test_serde_deserialize_error_triggers_fallback():
|
|
# DeepSeek Responses gateway rejecting the wire body (observed Aug 2026).
|
|
err = _FakeAPIError(400, {
|
|
"message": (
|
|
"Failed to deserialize the JSON body into the target type: "
|
|
"input: invalid type: string \"Michael topped up DeepSeek ...\", "
|
|
"expected a sequence at line 1 column 268612"
|
|
),
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
})
|
|
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is True
|
|
|
|
|
|
def test_invalid_type_error_triggers_fallback():
|
|
err = _FakeAPIError(422, "input[0]: invalid type: map, expected a string")
|
|
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is True
|
|
|
|
|
|
def test_unknown_field_error_triggers_fallback():
|
|
err = _FakeAPIError(400, "unknown field `foo`, expected one of `input`, `instructions`")
|
|
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is True
|
|
|
|
|
|
def test_legacy_compatibility_markers_still_trigger_fallback():
|
|
err = _FakeAPIError(400, "parameter `instructions` is unsupported")
|
|
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is True
|
|
|
|
|
|
def test_unrelated_400_does_not_trigger_fallback():
|
|
err = _FakeAPIError(400, {"message": "rate limit exceeded", "type": "rate_limit_error"})
|
|
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is False
|
|
|
|
|
|
def test_server_error_does_not_trigger_fallback():
|
|
err = _FakeAPIError(500, {"message": "internal server error"})
|
|
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is False
|