feat(providers): support DeepSeek Responses API (#5197)

This commit is contained in:
chengyongru
2026-08-01 11:53:51 +08:00
committed by GitHub
parent 971b977a84
commit cdb75f8e7d
9 changed files with 168 additions and 24 deletions
+21 -6
View File
@@ -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)
@@ -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({
+35 -13
View File
@@ -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]}")
+9 -2
View File
@@ -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),
+6
View File
@@ -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(