From cdb75f8e7d0011340c9434641fdbeba2560cd965 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:53:51 +0800 Subject: [PATCH] feat(providers): support DeepSeek Responses API (#5197) --- docs/configuration.md | 3 +- docs/providers.md | 2 + nanobot/providers/openai_compat_provider.py | 27 +++++++-- .../providers/openai_responses/converters.py | 13 ++++- nanobot/providers/openai_responses/parsing.py | 48 +++++++++++----- nanobot/providers/openai_responses/state.py | 11 +++- nanobot/providers/registry.py | 6 ++ tests/providers/test_openai_responses.py | 56 +++++++++++++++++++ .../test_responses_circuit_breaker.py | 26 +++++++++ 9 files changed, 168 insertions(+), 24 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 806017dbe..81bd0bd65 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -356,8 +356,7 @@ Providers that use the Responses API can keep reasoning context across a conversation, which helps with multi-step tasks. Supported providers can also compact long conversations automatically. -nanobot preserves Responses conversation state automatically for OpenAI -Responses, OpenAI Codex, Azure OpenAI, and compatible GitHub Copilot models. +nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models. Native compaction is also automatic when the provider supports it. The threshold is derived from the active model's context window and reserved output headroom; no provider configuration is required. diff --git a/docs/providers.md b/docs/providers.md index 1d0e3a482..4a0817917 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -231,6 +231,8 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the `providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. +DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions. + ### Custom OpenAI-Compatible Endpoint The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider. diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 4e52f9bba..7b6238f22 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -958,22 +958,34 @@ class OpenAICompatProvider(LLMProvider): model: str | None, reasoning_effort: str | None, ) -> bool: - """Use Responses API only for direct OpenAI requests that benefit from it.""" + """Choose Responses for providers/models that explicitly support it.""" if self._api_type == "chat_completions": return False - if self._spec and self._spec.name not in ("openai", "github_copilot"): + spec_name = self._spec.name if self._spec is not None else None + model_name = self._request_model_name(model or self.default_model).lower() + supported_models = { + supported.lower() + for supported in getattr(self._spec, "responses_models", ()) + } + model_responses = any( + model_name == supported or model_name.endswith(f"/{supported}") + for supported in supported_models + ) + provider_responses = spec_name in ("openai", "github_copilot") + if not provider_responses and not model_responses: return False if self._api_type == "responses": # Explicit configuration means Responses is mandatory; do not # consult the circuit breaker or fall back to Chat Completions. return True - if self._spec is None or self._spec.name != "github_copilot": + if provider_responses and (self._spec is None or self._spec.name != "github_copilot"): if not _is_direct_openai_base(self._effective_base): return False - model_name = (model or self.default_model).lower() wants = False - if reasoning_effort and reasoning_effort.lower() != "none": + if model_responses: + wants = True + elif reasoning_effort and reasoning_effort.lower() != "none": wants = True elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")): wants = True @@ -1099,11 +1111,13 @@ class OpenAICompatProvider(LLMProvider): self._sanitize_empty_content(sanitized_state.pending_messages) ) ) + preserve_reasoning = bool(self._spec and self._spec.name == "deepseek") instructions, input_items, replayed = prepare_responses_input( sanitized_messages, state=sanitized_state, provider=self._responses_state_provider(), model=model_name, + preserve_reasoning=preserve_reasoning, ) body: dict[str, Any] = { @@ -1131,7 +1145,7 @@ class OpenAICompatProvider(LLMProvider): if self._supports_temperature(model_name, reasoning_effort): body["temperature"] = temperature - if not self._supports_temperature(model_name, reasoning_effort): + if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning: body["include"] = ["reasoning.encrypted_content"] if reasoning_effort and reasoning_effort.lower() != "none": body["reasoning"] = {"effort": reasoning_effort} @@ -1827,6 +1841,7 @@ class OpenAICompatProvider(LLMProvider): _timed_stream(), on_content_delta, on_tool_call_delta=on_tool_call_delta, + on_reasoning_delta=on_thinking_delta, capture=capture, ) self._record_responses_success(model, reasoning_effort) diff --git a/nanobot/providers/openai_responses/converters.py b/nanobot/providers/openai_responses/converters.py index 903340b8b..f9dfe1a55 100644 --- a/nanobot/providers/openai_responses/converters.py +++ b/nanobot/providers/openai_responses/converters.py @@ -12,7 +12,11 @@ def _as_json_object(value: object) -> dict[str, Any] | None: return cast(dict[str, Any], value) if isinstance(value, dict) else None -def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: +def convert_messages( + messages: list[dict[str, Any]], + *, + preserve_reasoning: bool = False, +) -> tuple[str, list[dict[str, Any]]]: """Convert Chat Completions messages to Responses API input items. Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted @@ -36,6 +40,13 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str continue if role == "assistant": + if preserve_reasoning: + reasoning = msg.get("reasoning_content") + if isinstance(reasoning, str) and reasoning: + input_items.append({ + "type": "reasoning", + "content": reasoning, + }) if isinstance(content, str) and content: message_id = _unique_item_id(f"msg_{idx}", used_item_ids) input_items.append({ diff --git a/nanobot/providers/openai_responses/parsing.py b/nanobot/providers/openai_responses/parsing.py index b2de16855..da8dcf514 100644 --- a/nanobot/providers/openai_responses/parsing.py +++ b/nanobot/providers/openai_responses/parsing.py @@ -69,7 +69,9 @@ def _response_object(value: object) -> dict[str, Any] | None: return object_value dump = getattr(value, "model_dump", None) if callable(dump): - return _as_json_object(dump()) + dumped = _as_json_object(dump()) + if dumped is not None: + return dumped try: return _as_json_object(vars(value)) except TypeError: @@ -444,6 +446,14 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None: for item in _response_object_list(output): if item.get("type") != "reasoning": continue + content = item.get("content") + if isinstance(content, str) and content: + parts.append(content) + elif isinstance(content, list): + for block in _response_object_list(cast(list[object], content)): + text = block.get("text") + if isinstance(text, str) and text: + parts.append(text) for summary in _response_object_list(item.get("summary")): if summary.get("type") == "summary_text" and summary.get("text"): text = summary.get("text") @@ -483,11 +493,9 @@ def parse_response_output( if isinstance(refusal, str): content_parts.append(refusal) elif item_type == "reasoning": - for s in _response_object_list(item.get("summary")): - if s.get("type") == "summary_text" and s.get("text"): - text = s.get("text") - if isinstance(text, str): - reasoning_content = (reasoning_content or "") + text + text = _extract_reasoning_summary_from_output([item]) + if text: + reasoning_content = (reasoning_content or "") + text elif item_type == "function_call": call_id = item.get("call_id") or "" item_id = item.get("id") or "fc_0" @@ -532,6 +540,7 @@ async def consume_sdk_stream( stream: Any, on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, + on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None, capture: ResponsesStreamCapture | None = None, ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: """Consume an SDK async stream from ``client.responses.create(stream=True)``.""" @@ -542,6 +551,7 @@ async def consume_sdk_stream( finish_reason = "stop" usage: dict[str, int] = {} reasoning_content: str | None = None + streamed_reasoning = False refusal_seen = False refusal_deltas: dict[tuple[str | None, int | None], str] = {} emitted_refusal_text = "" @@ -572,6 +582,19 @@ async def consume_sdk_stream( content += delta_text if on_content_delta and delta_text: await on_content_delta(delta_text) + elif event_type == "response.reasoning_text.delta": + delta_text = getattr(event, "delta", "") or "" + if delta_text: + reasoning_content = (reasoning_content or "") + delta_text + streamed_reasoning = True + if on_reasoning_delta: + await on_reasoning_delta(delta_text) + elif event_type == "response.reasoning_text.done": + text = getattr(event, "text", "") or "" + if text and not streamed_reasoning and not reasoning_content: + reasoning_content = text + if on_reasoning_delta: + await on_reasoning_delta(text) elif event_type == "response.refusal.delta": refusal_seen = True delta_text = getattr(event, "delta", None) @@ -689,13 +712,12 @@ async def consume_sdk_stream( "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0), "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), } - for out_item in cast(list[Any], getattr(resp, "output", None) or []): - if getattr(out_item, "type", None) == "reasoning": - for s in cast(list[Any], getattr(out_item, "summary", None) or []): - if getattr(s, "type", None) == "summary_text": - text = getattr(s, "text", None) - if text: - reasoning_content = (reasoning_content or "") + text + if not reasoning_content: + reasoning_content = _extract_reasoning_summary_from_output( + getattr(resp, "output", None) + ) + if reasoning_content and on_reasoning_delta: + await on_reasoning_delta(reasoning_content) elif event_type in {"error", "response.failed"}: detail = getattr(event, "error", None) or getattr(event, "message", None) or event raise RuntimeError(f"Response failed: {str(detail)[:500]}") diff --git a/nanobot/providers/openai_responses/state.py b/nanobot/providers/openai_responses/state.py index 2424afb67..a2a95d70f 100644 --- a/nanobot/providers/openai_responses/state.py +++ b/nanobot/providers/openai_responses/state.py @@ -43,6 +43,7 @@ def prepare_responses_input( state: ProviderConversationState | None, provider: str, model: str, + preserve_reasoning: bool = False, ) -> tuple[str, list[dict[str, Any]], bool]: """Build a request from exact prior items plus only newly appended messages. @@ -50,7 +51,10 @@ def prepare_responses_input( When no compatible state exists, it is converted normally as a safe fallback. """ - instructions, fallback_items = convert_messages(messages) + instructions, fallback_items = convert_messages( + messages, + preserve_reasoning=preserve_reasoning, + ) if state is None or not responses_state_matches( state, provider=provider, @@ -62,7 +66,10 @@ def prepare_responses_input( if prior_items is None: return instructions, fallback_items, False - _, delta_items = convert_messages(state.pending_messages) + _, delta_items = convert_messages( + state.pending_messages, + preserve_reasoning=preserve_reasoning, + ) logger.debug( "Replaying Responses state: prior_items={} pending_messages={}", len(prior_items), diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index a4409f429..f46b54f90 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -111,6 +111,11 @@ class ProviderSpec: # Substring match against the wire model name (lowercased). implicit_reasoning_models: tuple[str, ...] = () + # Models that expose the OpenAI Responses wire format. This is model-level + # because providers may add Responses support incrementally (DeepSeek V4 + # Flash is supported before V4 Pro). + responses_models: tuple[str, ...] = () + # When the model returns content as a list of {"type":"thinking",...} + # {"type":"text",...} blocks, extract the thinking text into # reasoning_content. Mistral's Magistral / reasoning-enabled responses use @@ -461,6 +466,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( backend="openai_compat", default_api_base="https://api.deepseek.com", thinking_style="thinking_type", + responses_models=("deepseek-v4-flash",), ), # Gemini: Google's OpenAI-compatible endpoint ProviderSpec( diff --git a/tests/providers/test_openai_responses.py b/tests/providers/test_openai_responses.py index 0022c0cf5..f7824cac9 100644 --- a/tests/providers/test_openai_responses.py +++ b/tests/providers/test_openai_responses.py @@ -150,6 +150,22 @@ class TestConvertMessages: assert items[0]["content"][0]["type"] == "output_text" assert items[0]["content"][0]["text"] == "I'll help" + def test_preserves_deepseek_reasoning_content(self): + _, items = convert_messages([ + {"role": "assistant", "reasoning_content": "think first", "content": "answer"}, + ], preserve_reasoning=True) + + assert items == [ + {"type": "reasoning", "content": "think first"}, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "answer"}], + "status": "completed", + "id": "msg_0", + }, + ] + def test_assistant_empty_content_skipped(self): _, items = convert_messages([{"role": "assistant", "content": ""}]) assert len(items) == 0 @@ -539,6 +555,22 @@ class TestParseResponseOutput: assert result.content == "42" assert result.reasoning_content == "I think therefore I am." + def test_deepseek_reasoning_content_extracted(self): + resp = { + "output": [ + {"type": "reasoning", "content": "think first"}, + {"type": "message", "content": [ + {"type": "output_text", "text": "answer"}, + ]}, + ], + "status": "completed", "usage": {}, + } + + result = parse_response_output(resp) + + assert result.content == "answer" + assert result.reasoning_content == "think first" + def test_empty_output(self): resp = {"output": [], "status": "completed", "usage": {}} result = parse_response_output(resp) @@ -1633,6 +1665,30 @@ class TestConsumeSdkStream: _, _, _, _, reasoning = await consume_sdk_stream(stream()) assert reasoning == "thinking..." + @pytest.mark.asyncio + async def test_deepseek_reasoning_text_streamed(self): + events = [ + MagicMock(type="response.reasoning_text.delta", delta="step 1 "), + MagicMock(type="response.reasoning_text.delta", delta="step 2"), + MagicMock(type="response.reasoning_text.done", text="step 1 step 2"), + ] + emitted: list[str] = [] + + async def stream(): + for event in events: + yield event + + async def on_reasoning_delta(delta: str) -> None: + emitted.append(delta) + + _, _, _, _, reasoning = await consume_sdk_stream( + stream(), + on_reasoning_delta=on_reasoning_delta, + ) + + assert reasoning == "step 1 step 2" + assert emitted == ["step 1 ", "step 2"] + @pytest.mark.asyncio async def test_error_event_raises(self): ev = MagicMock(type="error", error="rate_limit_exceeded") diff --git a/tests/providers/test_responses_circuit_breaker.py b/tests/providers/test_responses_circuit_breaker.py index 83a588443..1b6c95cac 100644 --- a/tests/providers/test_responses_circuit_breaker.py +++ b/tests/providers/test_responses_circuit_breaker.py @@ -29,6 +29,32 @@ 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 = {}