mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(providers): keep reasoning items wire-valid for DeepSeek Responses
convert_messages() emitted reasoning items with ``content`` as a plain
string whenever preserve_reasoning was enabled (the DeepSeek spec).
DeepSeek's Responses gateway rejects that shape with a serde error
("input: invalid type: string ..., expected a sequence"), which surfaced
only after token consolidation cleared provider_state and forced the
full-history conversion path; replayed server items already carry list
content, which is why normal multi-turn requests never failed. Serialize
reasoning content as a list of output_text parts, matching the OpenAI
Responses schema and DeepSeek's accepted wire shape (verified live against
api.deepseek.com/responses).
The serde fallback classifier introduced in the previous commit remains as
a last-resort safeguard for any remaining wire incompatibility.
Tests: extend test_preserves_deepseek_reasoning_content to the array shape;
add a full-history regression with the observed failing item, a
replay/consolidation regression covering both replayed and converted
reasoning items, and provider-level request fixtures for both paths.
Full suite: 5773 passed, 22 skipped (only the known local-only
channels/sms packaging failure remains).
This commit is contained in:
parent
fb2688fd37
commit
6eda67b50c
@ -45,7 +45,7 @@ def convert_messages(
|
|||||||
if isinstance(reasoning, str) and reasoning:
|
if isinstance(reasoning, str) and reasoning:
|
||||||
input_items.append({
|
input_items.append({
|
||||||
"type": "reasoning",
|
"type": "reasoning",
|
||||||
"content": reasoning,
|
"content": [{"type": "output_text", "text": reasoning}],
|
||||||
})
|
})
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||||
|
|||||||
@ -156,7 +156,10 @@ class TestConvertMessages:
|
|||||||
], preserve_reasoning=True)
|
], preserve_reasoning=True)
|
||||||
|
|
||||||
assert items == [
|
assert items == [
|
||||||
{"type": "reasoning", "content": "think first"},
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"content": [{"type": "output_text", "text": "think first"}],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@ -166,6 +169,32 @@ class TestConvertMessages:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_reasoning_content_serialized_as_array_for_deepseek(self):
|
||||||
|
# Regression for PR #5214: DeepSeek's Responses gateway rejects
|
||||||
|
# reasoning items whose ``content`` is a plain string with
|
||||||
|
# "input: invalid type: string ..., expected a sequence" (observed
|
||||||
|
# after context consolidation cleared provider state and forced
|
||||||
|
# full-history conversion). ``content`` must be a list of parts,
|
||||||
|
# matching both the OpenAI Responses schema and DeepSeek's accepted
|
||||||
|
# wire shape.
|
||||||
|
_, items = convert_messages([
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"reasoning_content": "Michael topped up DeepSeek with $10.",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_1|fc_1",
|
||||||
|
"function": {"name": "list_dir", "arguments": "{}"},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
], preserve_reasoning=True)
|
||||||
|
|
||||||
|
assert items[0]["type"] == "reasoning"
|
||||||
|
assert items[0]["content"] == [
|
||||||
|
{"type": "output_text", "text": "Michael topped up DeepSeek with $10."},
|
||||||
|
]
|
||||||
|
assert items[1]["type"] == "function_call"
|
||||||
|
|
||||||
def test_assistant_empty_content_skipped(self):
|
def test_assistant_empty_content_skipped(self):
|
||||||
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
@ -824,6 +853,59 @@ class TestResponsesConversationState:
|
|||||||
}
|
}
|
||||||
assert "lossy public transcript" not in str(items)
|
assert "lossy public transcript" not in str(items)
|
||||||
|
|
||||||
|
def test_replayed_and_delta_reasoning_items_keep_array_content(self):
|
||||||
|
# Regression for PR #5214: token consolidation clears
|
||||||
|
# ``provider_state``, so the next turn converts the full history
|
||||||
|
# (including assistant reasoning) instead of replaying server items.
|
||||||
|
# Both paths must keep reasoning ``content`` as a list - DeepSeek's
|
||||||
|
# Responses gateway rejects the string form with a serde error.
|
||||||
|
prior_items = [
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_1",
|
||||||
|
"content": [{"type": "output_text", "text": "prior reasoning"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "output_text", "text": "prior answer"}],
|
||||||
|
"status": "completed",
|
||||||
|
"id": "msg_0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
state = build_responses_state(
|
||||||
|
provider="openai:test",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
input_items=prior_items,
|
||||||
|
output_items=[],
|
||||||
|
).with_pending_messages([
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"reasoning_content": "think before acting",
|
||||||
|
"content": "answer",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": "audit the tools"},
|
||||||
|
])
|
||||||
|
|
||||||
|
instructions, items, replayed = prepare_responses_input(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": "You are KITT."},
|
||||||
|
{"role": "user", "content": "audit the tools"},
|
||||||
|
],
|
||||||
|
state=state,
|
||||||
|
provider="openai:test",
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
preserve_reasoning=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert instructions == "You are KITT."
|
||||||
|
assert replayed is True
|
||||||
|
reasoning_items = [item for item in items if item.get("type") == "reasoning"]
|
||||||
|
assert len(reasoning_items) == 2 # one replayed, one converted delta
|
||||||
|
for item in reasoning_items:
|
||||||
|
assert isinstance(item["content"], list)
|
||||||
|
assert item["content"][0]["type"] == "output_text"
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# parsing - consume_sse
|
# parsing - consume_sse
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from nanobot.providers.openai_compat_provider import (
|
|||||||
_RESPONSES_PROBE_INTERVAL_S,
|
_RESPONSES_PROBE_INTERVAL_S,
|
||||||
OpenAICompatProvider,
|
OpenAICompatProvider,
|
||||||
)
|
)
|
||||||
|
from nanobot.providers.openai_responses.state import build_responses_state
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@ -202,3 +203,107 @@ def test_unrelated_400_does_not_trigger_fallback():
|
|||||||
def test_server_error_does_not_trigger_fallback():
|
def test_server_error_does_not_trigger_fallback():
|
||||||
err = _FakeAPIError(500, {"message": "internal server error"})
|
err = _FakeAPIError(500, {"message": "internal server error"})
|
||||||
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is False
|
assert OpenAICompatProvider._should_fallback_from_responses_error(err) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# DeepSeek Responses wire shape (PR #5214 root cause)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _deepseek_provider(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"
|
||||||
|
provider._extra_body = {}
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_full_history_body_keeps_reasoning_content_as_array(provider):
|
||||||
|
# Full-history fixture: DeepSeek's Responses gateway rejects reasoning
|
||||||
|
# items whose ``content`` is a plain string ("input: invalid type: string
|
||||||
|
# ..., expected a sequence"); the wire body must keep it as a part list.
|
||||||
|
_deepseek_provider(provider)
|
||||||
|
|
||||||
|
body = provider._build_responses_body(
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"reasoning_content": "Michael topped up DeepSeek with $10.",
|
||||||
|
"content": "All systems aligned now.",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": "audit the custom tools"},
|
||||||
|
],
|
||||||
|
tools=None,
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
max_tokens=1000,
|
||||||
|
temperature=0.1,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
reasoning_items = [item for item in body["input"] if item.get("type") == "reasoning"]
|
||||||
|
assert len(reasoning_items) == 1
|
||||||
|
assert reasoning_items[0]["content"] == [
|
||||||
|
{"type": "output_text", "text": "Michael topped up DeepSeek with $10."},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_replay_body_keeps_reasoning_content_as_array(provider):
|
||||||
|
# Replay/consolidation fixture: after token consolidation clears
|
||||||
|
# provider_state the next turn converts full history on top of the
|
||||||
|
# replayed prior items. Both replayed and converted reasoning items must
|
||||||
|
# keep list content on the wire.
|
||||||
|
_deepseek_provider(provider)
|
||||||
|
|
||||||
|
prior_items = [
|
||||||
|
{
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_1",
|
||||||
|
"content": [{"type": "output_text", "text": "prior reasoning"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "output_text", "text": "prior answer"}],
|
||||||
|
"status": "completed",
|
||||||
|
"id": "msg_0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
state = build_responses_state(
|
||||||
|
provider=provider._responses_state_provider(),
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
input_items=prior_items,
|
||||||
|
output_items=[],
|
||||||
|
).with_pending_messages([
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"reasoning_content": "think first",
|
||||||
|
"content": "answer",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": "audit the custom tools"},
|
||||||
|
])
|
||||||
|
|
||||||
|
body = provider._build_responses_body(
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": "You are KITT."},
|
||||||
|
{"role": "user", "content": "audit the custom tools"},
|
||||||
|
],
|
||||||
|
tools=None,
|
||||||
|
model="deepseek-v4-flash",
|
||||||
|
max_tokens=1000,
|
||||||
|
temperature=0.1,
|
||||||
|
reasoning_effort=None,
|
||||||
|
tool_choice=None,
|
||||||
|
provider_context=ProviderCallContext(conversation_state=state),
|
||||||
|
)
|
||||||
|
|
||||||
|
reasoning_items = [item for item in body["input"] if item.get("type") == "reasoning"]
|
||||||
|
assert len(reasoning_items) == 2 # one replayed from state, one converted
|
||||||
|
for item in reasoning_items:
|
||||||
|
assert isinstance(item["content"], list)
|
||||||
|
assert item["content"][0]["type"] == "output_text"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user