feat(providers): support DeepSeek Responses API (#5197)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user