feat: add stateless Responses API provider (#9515)

* feat: add stateless Responses API provider

* Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: use versioned DeepSeek Responses API base URL

* style: format Responses API provider

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Soulter
2026-08-02 23:15:50 +08:00
committed by GitHub
parent e4cf9b1681
commit 20c294fcaf
5 changed files with 1115 additions and 4 deletions
+29 -3
View File
@@ -1171,6 +1171,18 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"OpenAI Responses": {
"id": "openai-responses",
"provider": "openai",
"type": "openai_responses",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Google Gemini": {
"id": "google_gemini",
"provider": "google",
@@ -1284,7 +1296,7 @@ CONFIG_METADATA_2 = {
"xAI": {
"id": "xai",
"provider": "xai",
"type": "xai_chat_completion",
"type": "openai_responses",
"provider_type": "chat_completion",
"enable": True,
"key": [],
@@ -1292,7 +1304,6 @@ CONFIG_METADATA_2 = {
"timeout": 120,
"proxy": "",
"custom_headers": {},
"xai_native_search": False,
},
"DeepSeek": {
"id": "deepseek",
@@ -1306,6 +1317,18 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"DeepSeek Responses": {
"id": "deepseek-responses",
"provider": "deepseek",
"type": "openai_responses",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.deepseek.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Zhipu": {
"id": "zhipu",
"provider": "zhipu",
@@ -1998,7 +2021,10 @@ CONFIG_METADATA_2 = {
"description": "启用原生搜索功能",
"type": "bool",
"hint": "启用后,将通过 xAI 的 Chat Completions 原生 Live Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。",
"condition": {"provider": "xai"},
"condition": {
"provider": "xai",
"type": "xai_chat_completion",
},
},
"rerank_api_base": {
"description": "重排序模型 API Base URL",
+3 -1
View File
@@ -9,6 +9,7 @@ from anthropic.types import Message as AnthropicMessage
from deprecated import deprecated
from google.genai.types import GenerateContentResponse
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.responses import Response
import astrbot.core.message.components as Comp
from astrbot import logger
@@ -312,7 +313,7 @@ class LLMResponse:
"""The signature of the reasoning content, if any."""
raw_completion: (
ChatCompletion | GenerateContentResponse | AnthropicMessage | None
ChatCompletion | Response | GenerateContentResponse | AnthropicMessage | None
) = None
"""The raw completion response from the LLM provider."""
@@ -339,6 +340,7 @@ class LLMResponse:
reasoning_content: str | None = None,
reasoning_signature: str | None = None,
raw_completion: ChatCompletion
| Response
| GenerateContentResponse
| AnthropicMessage
| None = None,
+4
View File
@@ -367,6 +367,10 @@ class ProviderManager:
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "openai_responses":
from .sources.openai_responses_source import (
ProviderOpenAIResponses as ProviderOpenAIResponses,
)
case "longcat_chat_completion":
from .sources.longcat_source import ProviderLongCat as ProviderLongCat
case "minimax_token_plan":
@@ -0,0 +1,683 @@
import copy
import inspect
import json
from collections.abc import AsyncGenerator
from typing import Any
from openai.types.responses import Response
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.tool import ToolSet
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
from .request_retry import retry_provider_request
@register_provider_adapter(
"openai_responses",
"OpenAI-compatible Responses API provider adapter",
)
class ProviderOpenAIResponses(ProviderOpenAIOfficial):
"""OpenAI-compatible stateless Responses API provider adapter."""
_REASONING_STATE_TYPE = "openai_responses_reasoning"
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
"""Initialize the Responses API client.
Args:
provider_config: Provider source and model configuration.
provider_settings: Global provider settings.
"""
super().__init__(provider_config, provider_settings)
self.default_params = inspect.signature(
self.client.responses.create,
).parameters.keys()
@staticmethod
def _field(value: Any, name: str, default: Any = None) -> Any:
"""Read a field from an SDK model or a plain dictionary.
Args:
value: SDK model or dictionary to inspect.
name: Field name to read.
default: Value returned when the field is absent.
Returns:
The field value or the provided default.
"""
if isinstance(value, dict):
return value.get(name, default)
return getattr(value, name, default)
def _convert_chat_messages_to_response_input(
self,
messages: list[dict],
) -> list[dict]:
"""Convert AstrBot's OpenAI chat history to Responses input items.
The conversion preserves function call IDs and serialized reasoning output
items so the complete history can be replayed without server-side state.
Args:
messages: AstrBot context in OpenAI Chat Completions format.
Returns:
A list of Responses API input items.
"""
response_input: list[dict] = []
host = (self.client.base_url.host or "").rstrip(".").lower()
is_deepseek = (
self.provider_config.get("provider") == "deepseek"
or host == "api.deepseek.com"
)
for message in messages:
if not isinstance(message, dict):
continue
role = message.get("role")
if role == "tool":
tool_call_id = message.get("tool_call_id")
if not tool_call_id:
continue
output = message.get("content", "")
if not isinstance(output, str):
output = json.dumps(output, ensure_ascii=False, default=str)
response_input.append(
{
"type": "function_call_output",
"call_id": tool_call_id,
"output": output,
}
)
continue
if role not in {"user", "assistant", "system", "developer"}:
continue
content = message.get("content")
converted_content: str | list[dict] | None = None
reasoning_items: list[dict] = []
if isinstance(content, str):
converted_content = content
elif isinstance(content, list):
content_parts: list[dict] = []
assistant_text: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type == "think":
serialized_state = part.get("encrypted")
restored_items: list[dict] = []
if isinstance(serialized_state, str):
try:
state = json.loads(serialized_state)
except json.JSONDecodeError:
state = None
if (
isinstance(state, dict)
and state.get("type") == self._REASONING_STATE_TYPE
and isinstance(state.get("items"), list)
):
restored_items = [
item
for item in state["items"]
if isinstance(item, dict)
]
if restored_items:
reasoning_items.extend(restored_items)
elif is_deepseek and part.get("think"):
reasoning_items.append(
{
"type": "reasoning",
"content": [
{
"type": "reasoning_text",
"text": str(part["think"]),
}
],
"summary": [],
}
)
continue
if part_type == "text":
text = str(part.get("text", ""))
if role == "assistant":
assistant_text.append(text)
else:
content_parts.append({"type": "input_text", "text": text})
continue
if part_type == "image_url" and role != "assistant":
image_data = part.get("image_url")
if not isinstance(image_data, dict):
continue
image_url = image_data.get("url")
if not image_url:
continue
detail = image_data.get("detail", "auto")
if detail not in {"low", "high", "auto"}:
detail = "auto"
content_parts.append(
{
"type": "input_image",
"detail": detail,
"image_url": image_url,
}
)
continue
if part_type in {"audio_url", "input_audio"}:
if role == "assistant":
assistant_text.append("[Audio]")
else:
content_parts.append(
{"type": "input_text", "text": "[Audio]"}
)
if role == "assistant":
converted_content = "".join(assistant_text)
elif content_parts:
converted_content = content_parts
elif content is not None:
converted_content = str(content)
response_input.extend(reasoning_items)
if (
converted_content is not None
and converted_content != ""
and converted_content != []
):
response_input.append(
{
"type": "message",
"role": role,
"content": converted_content,
}
)
if role == "assistant":
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list):
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
function = tool_call.get("function")
call_id = tool_call.get("id")
if not isinstance(function, dict) or not call_id:
continue
arguments = function.get("arguments", "{}")
if not isinstance(arguments, str):
arguments = json.dumps(
arguments,
ensure_ascii=False,
default=str,
)
response_input.append(
{
"type": "function_call",
"call_id": call_id,
"name": function.get("name", ""),
"arguments": arguments,
}
)
return response_input
async def _prepare_chat_payload(
self,
prompt: str | None,
image_urls: list[str] | None = None,
audio_urls: list[str] | None = None,
contexts: list[dict] | list[Message] | None = None,
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs: Any,
) -> tuple[dict, list[dict]]:
"""Build a stateless Responses API payload and replayable context.
Args:
prompt: Current user prompt.
image_urls: Image references attached to the prompt.
audio_urls: Audio references attached to the prompt.
contexts: Existing AstrBot conversation history.
system_prompt: System-level instructions for this request.
tool_calls_result: Function calls and their returned outputs.
model: Optional per-request model override.
extra_user_content_parts: Additional user content blocks.
**kwargs: Reserved provider request arguments.
Returns:
The Responses payload and its chat-format source context.
"""
context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts))
if prompt is not None:
context_query.append(
await self.assemble_context(
prompt or "",
image_urls,
audio_urls,
extra_user_content_parts,
)
)
for message in context_query:
if isinstance(message, dict):
message.pop("_no_save", None)
if tool_calls_result:
if isinstance(tool_calls_result, ToolCallsResult):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for result in tool_calls_result:
context_query.extend(result.to_openai_messages())
if self._context_contains_image(context_query):
context_query = await self._materialize_context_image_parts(context_query)
payloads: dict[str, Any] = {
"input": self._convert_chat_messages_to_response_input(context_query),
"model": model or self.get_model(),
"store": False,
}
if system_prompt:
payloads["instructions"] = system_prompt
return payloads, context_query
async def _query(
self,
payloads: dict,
tools: ToolSet | None,
*,
request_max_retries: int | None = None,
) -> LLMResponse:
"""Send a non-streaming Responses API request.
Args:
payloads: Prepared Responses API payload.
tools: Functions available to the model.
request_max_retries: Maximum transport-level request attempts.
Returns:
Normalized AstrBot LLM response.
Raises:
TypeError: If the SDK returns an unexpected response type.
"""
if tools:
response_tools = []
for tool in tools.openai_schema():
function = tool.get("function", {})
response_tools.append({"type": "function", **function})
if response_tools:
payloads["tools"] = response_tools
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
extra_body: dict[str, Any] = {}
custom_extra_body = self.provider_config.get("custom_extra_body", {})
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
for key in list(payloads):
if key not in self.default_params:
extra_body[key] = payloads.pop(key)
max_tokens = extra_body.pop("max_tokens", None)
if max_tokens is not None and "max_output_tokens" not in extra_body:
extra_body["max_output_tokens"] = max_tokens
reasoning_effort = extra_body.pop("reasoning_effort", None)
if reasoning_effort is not None and "reasoning" not in extra_body:
extra_body["reasoning"] = {"effort": reasoning_effort}
extra_body.pop("previous_response_id", None)
extra_body.pop("conversation", None)
extra_body.pop("store", None)
payloads.pop("previous_response_id", None)
payloads.pop("conversation", None)
payloads["store"] = False
response = await retry_provider_request(
"OpenAI Responses",
lambda: self.client.responses.create(
**payloads,
stream=False,
extra_body=extra_body,
),
max_attempts=request_max_retries,
)
if not isinstance(response, Response):
raise TypeError(
f"Responses API returned an unexpected type: {type(response)}: "
f"{response}."
)
logger.debug("response: %s", response)
return await self._parse_response(response, tools)
async def _query_stream(
self,
payloads: dict,
tools: ToolSet | None,
*,
request_max_retries: int | None = None,
) -> AsyncGenerator[LLMResponse, None]:
"""Send a streaming Responses API request.
Args:
payloads: Prepared Responses API payload.
tools: Functions available to the model.
request_max_retries: Maximum transport-level request attempts.
Yields:
Text/reasoning deltas followed by one complete normalized response.
Raises:
EmptyModelOutputError: If the stream ends without a terminal event.
"""
if tools:
response_tools = []
for tool in tools.openai_schema():
function = tool.get("function", {})
response_tools.append({"type": "function", **function})
if response_tools:
payloads["tools"] = response_tools
payloads["tool_choice"] = payloads.get("tool_choice", "auto")
extra_body: dict[str, Any] = {}
custom_extra_body = self.provider_config.get("custom_extra_body", {})
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
for key in list(payloads):
if key not in self.default_params:
extra_body[key] = payloads.pop(key)
max_tokens = extra_body.pop("max_tokens", None)
if max_tokens is not None and "max_output_tokens" not in extra_body:
extra_body["max_output_tokens"] = max_tokens
reasoning_effort = extra_body.pop("reasoning_effort", None)
if reasoning_effort is not None and "reasoning" not in extra_body:
extra_body["reasoning"] = {"effort": reasoning_effort}
extra_body.pop("previous_response_id", None)
extra_body.pop("conversation", None)
extra_body.pop("store", None)
payloads.pop("previous_response_id", None)
payloads.pop("conversation", None)
payloads["store"] = False
stream = await retry_provider_request(
"OpenAI Responses",
lambda: self.client.responses.create(
**payloads,
stream=True,
extra_body=extra_body,
),
max_attempts=request_max_retries,
)
response_id: str | None = None
async for event in stream:
event_type = self._field(event, "type", "")
event_response = self._field(event, "response")
if event_response is not None:
response_id = self._field(event_response, "id", response_id)
if event_type == "error":
code = self._field(event, "code", "stream_error")
message = self._field(event, "message", "Responses stream failed")
raise RuntimeError(
f"Responses API stream failed: {code}: {message}. "
f"response_id={response_id}"
)
if event_type in {
"response.output_text.delta",
"response.refusal.delta",
}:
delta = self._field(event, "delta", "")
if delta:
yield LLMResponse(
"assistant",
result_chain=MessageChain(chain=[Comp.Plain(str(delta))]),
is_chunk=True,
id=response_id,
)
continue
if event_type in {
"response.reasoning_text.delta",
"response.reasoning_summary_text.delta",
}:
delta = self._field(event, "delta", "")
if delta:
yield LLMResponse(
"assistant",
reasoning_content=str(delta),
is_chunk=True,
id=response_id,
)
continue
if event_type in {
"response.completed",
"response.incomplete",
"response.failed",
}:
if event_response is None:
raise EmptyModelOutputError(
f"Responses stream terminal event has no response: {event_type}"
)
yield await self._parse_response(event_response, tools)
return
raise EmptyModelOutputError(
f"Responses stream ended without a terminal event. response_id={response_id}"
)
async def _parse_response(
self,
response: Response,
tools: ToolSet | None,
) -> LLMResponse:
"""Normalize a Responses API response into AstrBot's LLM response.
Args:
response: SDK Responses API response object.
tools: Functions available for resolving function call output items.
Returns:
Normalized AstrBot LLM response.
Raises:
EmptyModelOutputError: If the response contains no usable output.
RuntimeError: If the provider reports a failed response.
"""
response_id = self._field(response, "id")
status = self._field(response, "status")
if status == "failed":
error = self._field(response, "error")
code = self._field(error, "code", "unknown_error")
message = self._field(error, "message", "Responses API request failed")
raise RuntimeError(
f"Responses API request failed: {code}: {message}. "
f"response_id={response_id}"
)
incomplete_details = self._field(response, "incomplete_details")
if self._field(incomplete_details, "reason") == "content_filter":
raise RuntimeError(
"Responses API output was rejected by the provider content filter. "
f"response_id={response_id}"
)
llm_response = LLMResponse("assistant", id=response_id)
text_parts: list[str] = []
reasoning_parts: list[str] = []
serialized_reasoning_items: list[dict] = []
for item in self._field(response, "output", []) or []:
item_type = self._field(item, "type")
if item_type == "message":
for content in self._field(item, "content", []) or []:
content_type = self._field(content, "type")
if content_type == "output_text":
text_parts.append(str(self._field(content, "text", "")))
elif content_type == "refusal":
text_parts.append(str(self._field(content, "refusal", "")))
continue
if item_type == "reasoning":
if hasattr(item, "model_dump"):
serialized_item = item.model_dump(mode="json", exclude_none=True)
elif isinstance(item, dict):
serialized_item = copy.deepcopy(item)
else:
serialized_item = {}
if serialized_item:
serialized_reasoning_items.append(serialized_item)
item_reasoning: list[str] = []
for content in self._field(item, "content", []) or []:
if self._field(content, "type") == "reasoning_text":
item_reasoning.append(str(self._field(content, "text", "")))
if not item_reasoning:
for summary in self._field(item, "summary", []) or []:
summary_text = self._field(summary, "text", "")
if summary_text:
item_reasoning.append(str(summary_text))
reasoning_parts.extend(item_reasoning)
continue
if item_type == "function_call" and tools is not None:
arguments = self._field(item, "arguments", "{}")
if isinstance(arguments, str):
try:
parsed_arguments = json.loads(arguments)
except json.JSONDecodeError as exc:
logger.error("Failed to parse function arguments: %s", exc)
parsed_arguments = {}
else:
parsed_arguments = arguments
if parsed_arguments is None:
parsed_arguments = {}
llm_response.tools_call_args.append(parsed_arguments)
llm_response.tools_call_name.append(str(self._field(item, "name", "")))
llm_response.tools_call_ids.append(
str(self._field(item, "call_id", ""))
)
completion_text = "".join(text_parts)
if completion_text:
llm_response.result_chain = MessageChain().message(completion_text)
if reasoning_parts:
llm_response.reasoning_content = "\n".join(reasoning_parts)
if serialized_reasoning_items:
llm_response.reasoning_signature = json.dumps(
{
"type": self._REASONING_STATE_TYPE,
"items": serialized_reasoning_items,
},
ensure_ascii=False,
)
if llm_response.tools_call_args:
llm_response.role = "tool"
usage = self._field(response, "usage")
if usage is not None:
input_details = self._field(usage, "input_tokens_details")
cached_tokens = self._field(input_details, "cached_tokens", 0) or 0
input_tokens = self._field(usage, "input_tokens", 0) or 0
output_tokens = self._field(usage, "output_tokens", 0) or 0
llm_response.usage = TokenUsage(
input_other=input_tokens - cached_tokens,
input_cached=cached_tokens,
output=output_tokens,
)
else:
llm_response.usage = TokenUsage()
has_text = bool((llm_response.completion_text or "").strip())
has_reasoning = bool((llm_response.reasoning_content or "").strip())
if not has_text and not has_reasoning and not llm_response.tools_call_args:
raise EmptyModelOutputError(
"Responses API returned no usable output. "
f"response_id={response_id}, status={status}"
)
llm_response.raw_completion = response
return llm_response
async def _handle_api_error(
self,
error: Exception,
payloads: dict,
context_query: list,
func_tool: ToolSet | None,
chosen_key: str,
available_api_keys: list[str],
retry_cnt: int,
max_retries: int,
image_fallback_used: bool = False,
) -> tuple:
"""Reuse common recovery behavior with chat-format source history.
Args:
error: Provider request error.
payloads: Current Responses payload.
context_query: Chat-format source history used to build ``input``.
func_tool: Functions currently available to the model.
chosen_key: API key used for the failed request.
available_api_keys: Remaining API keys available for rotation.
retry_cnt: Current retry index.
max_retries: Maximum provider-level retries.
image_fallback_used: Whether image fallback already ran.
Returns:
The common retry state tuple with a rebuilt Responses input payload.
"""
compatibility_payloads = dict(payloads)
compatibility_payloads["messages"] = context_query
result = await super()._handle_api_error(
error,
compatibility_payloads,
context_query,
func_tool,
chosen_key,
available_api_keys,
retry_cnt,
max_retries,
image_fallback_used=image_fallback_used,
)
(
success,
chosen_key,
available_api_keys,
retry_payloads,
context_query,
func_tool,
image_fallback_used,
) = result
retry_payloads.pop("messages", None)
retry_payloads["input"] = self._convert_chat_messages_to_response_input(
context_query
)
retry_payloads["store"] = False
return (
success,
chosen_key,
available_api_keys,
retry_payloads,
context_query,
func_tool,
image_fallback_used,
)
+396
View File
@@ -0,0 +1,396 @@
import json
from types import SimpleNamespace
import pytest
from openai.types.responses import Response
from astrbot.core.config.default import CONFIG_METADATA_2
from astrbot.core.provider.sources.openai_responses_source import (
ProviderOpenAIResponses,
)
def _make_provider(overrides: dict | None = None) -> ProviderOpenAIResponses:
provider_config = {
"id": "test-responses",
"provider": "openai",
"type": "openai_responses",
"model": "gpt-test",
"key": ["test-key"],
"api_base": "https://api.openai.com/v1",
}
if overrides:
provider_config.update(overrides)
return ProviderOpenAIResponses(provider_config, {})
def _make_response(output: list[dict], **overrides) -> Response:
payload = {
"id": "resp_1",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-test",
"output": output,
"usage": {
"input_tokens": 10,
"input_tokens_details": {"cached_tokens": 3},
"output_tokens": 4,
"output_tokens_details": {"reasoning_tokens": 2},
"total_tokens": 14,
},
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
}
payload.update(overrides)
return Response.model_validate(payload)
def test_responses_provider_templates_are_independent_and_stateless():
templates = CONFIG_METADATA_2["provider_group"]["metadata"]["provider"][
"config_template"
]
assert templates["OpenAI Responses"]["type"] == "openai_responses"
assert templates["OpenAI Responses"]["api_base"] == "https://api.openai.com/v1"
assert templates["DeepSeek Responses"]["type"] == "openai_responses"
assert templates["DeepSeek Responses"]["api_base"] == "https://api.deepseek.com/v1"
assert templates["xAI"]["type"] == "openai_responses"
assert templates["xAI"]["api_base"] == "https://api.x.ai/v1"
assert "xai_native_search" not in templates["xAI"]
def test_convert_chat_history_preserves_response_items_and_function_calls():
provider = _make_provider()
reasoning_item = {
"id": "rs_1",
"type": "reasoning",
"status": "completed",
"summary": [],
"encrypted_content": "encrypted-reasoning",
}
reasoning_state = json.dumps(
{
"type": provider._REASONING_STATE_TYPE,
"items": [reasoning_item],
}
)
response_input = provider._convert_chat_messages_to_response_input(
[
{"role": "system", "content": "system context"},
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,AAAA",
"detail": "high",
},
},
],
},
{
"role": "assistant",
"content": [
{
"type": "think",
"think": "hidden",
"encrypted": reasoning_state,
},
{"type": "text", "text": "calling"},
],
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "weather", "arguments": '{"city":"SZ"}'},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
]
)
assert response_input == [
{"type": "message", "role": "system", "content": "system context"},
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "look"},
{
"type": "input_image",
"detail": "high",
"image_url": "data:image/png;base64,AAAA",
},
],
},
reasoning_item,
{"type": "message", "role": "assistant", "content": "calling"},
{
"type": "function_call",
"call_id": "call_1",
"name": "weather",
"arguments": '{"city":"SZ"}',
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "sunny",
},
]
def test_deepseek_converts_plain_reasoning_history_to_reasoning_item():
provider = _make_provider(
{
"provider": "deepseek",
"api_base": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
}
)
response_input = provider._convert_chat_messages_to_response_input(
[
{
"role": "assistant",
"content": [
{"type": "think", "think": "prior thought"},
{"type": "text", "text": "prior answer"},
],
}
]
)
assert response_input == [
{
"type": "reasoning",
"content": [
{"type": "reasoning_text", "text": "prior thought"},
],
"summary": [],
},
{"type": "message", "role": "assistant", "content": "prior answer"},
]
@pytest.mark.asyncio
async def test_prepare_payload_replays_full_history_without_server_state():
provider = _make_provider()
payloads, context = await provider._prepare_chat_payload(
prompt="current",
contexts=[{"role": "user", "content": "previous"}],
system_prompt="follow instructions",
)
assert context == [
{"role": "user", "content": "previous"},
{"role": "user", "content": "current"},
]
assert payloads == {
"model": "gpt-test",
"store": False,
"instructions": "follow instructions",
"input": [
{"type": "message", "role": "user", "content": "previous"},
{"type": "message", "role": "user", "content": "current"},
],
}
assert "previous_response_id" not in payloads
assert "conversation" not in payloads
@pytest.mark.asyncio
async def test_query_flattens_tools_and_enforces_stateless_body(monkeypatch):
provider = _make_provider(
{
"custom_extra_body": {
"max_tokens": 321,
"reasoning_effort": "low",
"previous_response_id": "resp_previous",
"conversation": "conv_1",
"store": True,
}
}
)
captured: dict = {}
async def fake_create(**kwargs):
captured.update(kwargs)
return _make_response(
[
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "weather",
"arguments": '{"city":"SZ"}',
"status": "completed",
}
]
)
monkeypatch.setattr(provider.client.responses, "create", fake_create)
tools = SimpleNamespace(
openai_schema=lambda: [
{
"type": "function",
"function": {
"name": "weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
]
)
result = await provider._query(
{
"model": "gpt-test",
"input": "weather",
"store": True,
"previous_response_id": "resp_direct",
"conversation": "conv_direct",
},
tools,
)
assert captured["store"] is False
assert captured["stream"] is False
assert "previous_response_id" not in captured
assert "conversation" not in captured
assert captured["tools"] == [
{
"type": "function",
"name": "weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
}
]
assert captured["extra_body"] == {
"max_output_tokens": 321,
"reasoning": {"effort": "low"},
}
assert result.role == "tool"
assert result.tools_call_name == ["weather"]
assert result.tools_call_args == [{"city": "SZ"}]
assert result.tools_call_ids == ["call_1"]
@pytest.mark.asyncio
async def test_parse_response_extracts_text_reasoning_usage_and_replay_state():
provider = _make_provider()
response = _make_response(
[
{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [],
"content": [
{"type": "reasoning_text", "text": "thinking"},
],
},
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{"type": "output_text", "text": "answer", "annotations": []},
],
},
]
)
result = await provider._parse_response(response, tools=None)
assert result.completion_text == "answer"
assert result.reasoning_content == "thinking"
assert result.usage.input_other == 7
assert result.usage.input_cached == 3
assert result.usage.output == 4
assert result.raw_completion is response
state = json.loads(result.reasoning_signature)
assert state["type"] == provider._REASONING_STATE_TYPE
assert state["items"][0]["id"] == "rs_1"
assert state["items"][0]["content"] == [
{"text": "thinking", "type": "reasoning_text"}
]
@pytest.mark.asyncio
async def test_query_stream_yields_semantic_deltas_and_final_response(monkeypatch):
provider = _make_provider()
final_response = _make_response(
[
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [
{"type": "output_text", "text": "hello", "annotations": []},
],
}
]
)
captured: dict = {}
async def fake_stream():
yield SimpleNamespace(
type="response.created",
response=SimpleNamespace(id="resp_1"),
)
yield SimpleNamespace(type="response.reasoning_text.delta", delta="think")
yield SimpleNamespace(type="response.output_text.delta", delta="hello")
yield SimpleNamespace(type="response.completed", response=final_response)
async def fake_create(**kwargs):
captured.update(kwargs)
return fake_stream()
monkeypatch.setattr(provider.client.responses, "create", fake_create)
results = [
result
async for result in provider._query_stream(
{"model": "gpt-test", "input": "hi", "store": False},
tools=None,
)
]
assert captured["stream"] is True
assert captured["store"] is False
assert len(results) == 3
assert results[0].is_chunk is True
assert results[0].reasoning_content == "think"
assert results[1].is_chunk is True
assert results[1].completion_text == "hello"
assert results[2].is_chunk is False
assert results[2].completion_text == "hello"
@pytest.mark.asyncio
async def test_parse_failed_response_raises_provider_error():
provider = _make_provider()
response = _make_response(
[],
status="failed",
error={"code": "server_error", "message": "failed"},
usage=None,
)
with pytest.raises(RuntimeError, match="server_error: failed"):
await provider._parse_response(response, tools=None)