mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 07:20:10 +00:00
OpenAI Responses provider: reasoning + tools for native OpenAI models
/v1/chat/completions rejects function tools with any reasoning_effort other than none on GPT-5.6, so native OpenAI has run with reasoning OFF. OpenAIResponsesProvider speaks /v1/responses instead: reasoning + tools at real effort, streamed reasoning summaries into the existing reasoning_delta plumbing, and CoT continuity across tool round-trips via store:false + encrypted reasoning replayed through a new _openai message sidecar (same extras contract as _anthropic/_gemini). Not routed yet.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
"""OpenAI Responses provider — native OpenAI models via `/v1/responses`.
|
||||
|
||||
Chat Completions rejects function tools combined with any `reasoning_effort` other than
|
||||
`none` on GPT-5.6+ ("use /v1/responses"), which had reasoning pinned OFF for native OpenAI
|
||||
models (see `openai_provider._pin_reasoning_effort`). This provider is the Responses path:
|
||||
reasoning + tools at real effort levels, streamed reasoning summaries (→ the same
|
||||
`reasoning_delta` / `AssistantTurn.reasoning` plumbing the GUI already renders), and
|
||||
chain-of-thought continuity across tool round-trips via `store: false` +
|
||||
`include: ["reasoning.encrypted_content"]` — nothing retained server-side.
|
||||
|
||||
Routing: the `openai` provider entry with NO custom base_url builds this class; a custom
|
||||
endpoint (Azure, vLLM, any OpenAI-compatible gateway) and every compat vendor keep the
|
||||
Chat Completions `OpenAIProvider` (registry.py).
|
||||
|
||||
Like the other native providers, this is mostly a pair of pure converters from the
|
||||
canonical OpenAI-chat-shaped history to Responses `input` items. What the converters
|
||||
must absorb:
|
||||
|
||||
- The system prompt is the `instructions` request field, not a message role.
|
||||
- Assistant tool calls are top-level `function_call` items; tool results are
|
||||
`function_call_output` items paired by `call_id` (ids only need to pair up, so foreign
|
||||
`toolu_…` ids from a mid-conversation provider switch are fine).
|
||||
- Tool schemas are FLAT (`{"type": "function", "name", …}` — no nested `function` key).
|
||||
- Reasoning continuity: the raw output items (reasoning item with `encrypted_content`,
|
||||
`function_call` items with their ids) ride the canonical assistant message as the
|
||||
`_openai` sidecar (see providers/base.py). Present → replayed verbatim for exact CoT
|
||||
continuity; absent (history from another provider) → items are synthesized from the
|
||||
canonical fields. Reasoning items WITHOUT `encrypted_content` never enter the sidecar:
|
||||
with `store: false` the server can't resolve them and would reject the replay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import (
|
||||
AssistantTurn,
|
||||
ModelCapabilities,
|
||||
ProviderClient,
|
||||
StreamChunk,
|
||||
ToolCall,
|
||||
)
|
||||
from .capabilities import capabilities_for
|
||||
from .openai_provider import resolve_api_key
|
||||
|
||||
# Request params passed through from model settings; everything else (frequency_penalty,
|
||||
# reasoning_effort — no effort knob in v1, the server default rides) is dropped.
|
||||
_SETTINGS_WHITELIST = {
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_output_tokens",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
}
|
||||
|
||||
# "Unsupported parameter: 'temperature' is not supported with this model." — reasoning
|
||||
# models reject sampling params; non-reasoning models reject `reasoning`/`include`. The
|
||||
# server names exactly one offender per error, so each retry drops exactly that.
|
||||
_UNSUPPORTED_PARAM = re.compile(r"unsupported (?:parameter|value)s?:?\s*'([^']+)'")
|
||||
|
||||
|
||||
def _param_fix_retry(kwargs: dict[str, Any], exc: Exception) -> dict[str, Any]:
|
||||
"""Kwargs for the one retry an unsupported-parameter error earns, or re-raise.
|
||||
|
||||
Same contract as the Chat Completions retries: fix exactly what the server named.
|
||||
A dotted name (`reasoning.summary`) drops its top-level param.
|
||||
"""
|
||||
match = _UNSUPPORTED_PARAM.search(str(exc).lower())
|
||||
if match:
|
||||
param = match.group(1).split(".", 1)[0].split("[", 1)[0]
|
||||
if param in kwargs and param not in ("model", "input"):
|
||||
fixed = dict(kwargs)
|
||||
del fixed[param]
|
||||
return fixed
|
||||
raise exc
|
||||
|
||||
|
||||
def _user_content(content: Any) -> Any:
|
||||
"""User content (str or OpenAI chat parts) → Responses content (str or input parts)."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
parts: list[dict[str, Any]] = []
|
||||
for part in content or []:
|
||||
kind = part.get("type") if isinstance(part, dict) else None
|
||||
if kind == "text":
|
||||
parts.append({"type": "input_text", "text": part.get("text") or ""})
|
||||
elif kind == "image_url":
|
||||
url = (part.get("image_url") or {}).get("url") or ""
|
||||
parts.append({"type": "input_image", "image_url": url})
|
||||
elif kind == "file":
|
||||
file = part.get("file") or {}
|
||||
entry: dict[str, Any] = {"type": "input_file"}
|
||||
if file.get("filename"):
|
||||
entry["filename"] = file["filename"]
|
||||
if file.get("file_data"):
|
||||
entry["file_data"] = file["file_data"]
|
||||
parts.append(entry)
|
||||
return parts
|
||||
|
||||
|
||||
def _synthesized_items(message: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""An assistant message WITHOUT a usable `_openai` sidecar (history produced by another
|
||||
provider before a switch) → items rebuilt from the canonical fields."""
|
||||
items: list[dict[str, Any]] = []
|
||||
text = message.get("content")
|
||||
if isinstance(text, str) and text:
|
||||
items.append({"role": "assistant", "content": text})
|
||||
for call in message.get("tool_calls") or []:
|
||||
function = call.get("function") or {}
|
||||
arguments = function.get("arguments")
|
||||
if not isinstance(arguments, str):
|
||||
arguments = json.dumps(arguments or {})
|
||||
items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": call.get("id") or "",
|
||||
"name": function.get("name") or "",
|
||||
"arguments": arguments,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def convert_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> tuple[Optional[str], list[dict[str, Any]]]:
|
||||
"""Canonical OpenAI-chat history → (`instructions`, Responses `input` items).
|
||||
|
||||
Leading system messages join into `instructions`; a stray mid-thread system message
|
||||
rides as a system message item. Assistant messages replay their `_openai` sidecar
|
||||
verbatim when present (exact CoT continuity), else synthesize from canonical fields.
|
||||
"""
|
||||
system_parts: list[str] = []
|
||||
index = 0
|
||||
while index < len(messages) and messages[index].get("role") == "system":
|
||||
content = messages[index].get("content")
|
||||
if isinstance(content, str) and content:
|
||||
system_parts.append(content)
|
||||
index += 1
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for message in messages[index:]:
|
||||
role = message.get("role")
|
||||
if role == "system":
|
||||
text = message.get("content") or ""
|
||||
if text:
|
||||
items.append({"role": "system", "content": text})
|
||||
elif role == "user":
|
||||
content = _user_content(message.get("content"))
|
||||
if content:
|
||||
items.append({"role": "user", "content": content})
|
||||
elif role == "assistant":
|
||||
sidecar = message.get("_openai") or {}
|
||||
replay = sidecar.get("items") or []
|
||||
if replay:
|
||||
items.extend(replay)
|
||||
else:
|
||||
items.extend(_synthesized_items(message))
|
||||
elif role == "tool":
|
||||
content = message.get("content")
|
||||
items.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": message.get("tool_call_id") or "",
|
||||
"output": content if isinstance(content, str) else str(content or ""),
|
||||
}
|
||||
)
|
||||
|
||||
return ("\n\n".join(system_parts) or None), items
|
||||
|
||||
|
||||
def convert_tools(tools: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
"""OpenAI chat function schemas → Responses FLAT tool entries (no nested `function`)."""
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools or []:
|
||||
function = (tool or {}).get("function") or {}
|
||||
name = function.get("name")
|
||||
if not name:
|
||||
continue
|
||||
entry: dict[str, Any] = {"type": "function", "name": name}
|
||||
if function.get("description"):
|
||||
entry["description"] = function["description"]
|
||||
if function.get("parameters") is not None:
|
||||
entry["parameters"] = function["parameters"]
|
||||
converted.append(entry)
|
||||
return converted
|
||||
|
||||
|
||||
def _dump(value: Any) -> Any:
|
||||
"""An output item (SDK model, dict, or test namespace) → plain jsonl-safe data."""
|
||||
if isinstance(value, dict):
|
||||
return {k: _dump(v) for k, v in value.items() if v is not None}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_dump(v) for v in value]
|
||||
dump = getattr(value, "model_dump", None)
|
||||
if callable(dump):
|
||||
return dump(exclude_none=True)
|
||||
if hasattr(value, "__dict__"): # SimpleNamespace fakes in tests
|
||||
return {k: _dump(v) for k, v in vars(value).items() if v is not None}
|
||||
return value
|
||||
|
||||
|
||||
def _parse_arguments(raw: Any) -> dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {"_raw": raw}
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
# Surface unparseable arguments rather than dropping the call; the engine
|
||||
# can return a tool-error so the model corrects itself.
|
||||
return {"_raw": raw}
|
||||
|
||||
|
||||
def _sidecar_extras(items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Output items → the `_openai` sidecar, or {} when replay would add nothing.
|
||||
|
||||
Reasoning items without `encrypted_content` are dropped: under `store: false` the
|
||||
server can't resolve them by id and rejects the replay. The sidecar is only worth
|
||||
persisting when something beyond plain answer text needs continuity.
|
||||
"""
|
||||
kept = [
|
||||
item
|
||||
for item in items
|
||||
if item.get("type") != "reasoning" or item.get("encrypted_content")
|
||||
]
|
||||
if any(item.get("type") in ("reasoning", "function_call") for item in kept):
|
||||
return {"_openai": {"items": kept}}
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_response(response: Any) -> AssistantTurn:
|
||||
"""One Responses result → an AssistantTurn (+ `_openai` extras)."""
|
||||
items = [_dump(item) for item in getattr(response, "output", None) or []]
|
||||
texts: list[str] = []
|
||||
summaries: list[str] = []
|
||||
tool_calls: list[ToolCall] = []
|
||||
for item in items:
|
||||
kind = item.get("type")
|
||||
if kind == "message" or (kind is None and "content" in item):
|
||||
content = item.get("content")
|
||||
if isinstance(content, str):
|
||||
texts.append(content)
|
||||
else:
|
||||
for part in content or []:
|
||||
if part.get("type") == "output_text" and part.get("text"):
|
||||
texts.append(part["text"])
|
||||
elif kind == "reasoning":
|
||||
for part in item.get("summary") or []:
|
||||
text = part.get("text") if isinstance(part, dict) else part
|
||||
if text:
|
||||
summaries.append(text)
|
||||
elif kind == "function_call":
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=item.get("call_id") or item.get("id") or "",
|
||||
name=item.get("name") or "",
|
||||
arguments=_parse_arguments(item.get("arguments")),
|
||||
)
|
||||
)
|
||||
|
||||
incomplete = _dump(getattr(response, "incomplete_details", None)) or {}
|
||||
if tool_calls:
|
||||
finish = "tool_calls"
|
||||
elif incomplete.get("reason") == "max_output_tokens":
|
||||
finish = "length"
|
||||
else:
|
||||
finish = "stop"
|
||||
|
||||
return AssistantTurn(
|
||||
text="".join(texts) or None,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish,
|
||||
raw=response,
|
||||
reasoning="".join(summaries) or None,
|
||||
extras=_sidecar_extras(items),
|
||||
)
|
||||
|
||||
|
||||
class OpenAIResponsesProvider(ProviderClient):
|
||||
def __init__(
|
||||
self,
|
||||
client: Any = None,
|
||||
*,
|
||||
default_model: str = "gpt-5.6-sol",
|
||||
api_key: Optional[str] = None,
|
||||
secrets: Any = None,
|
||||
):
|
||||
# Same deferred-client contract as OpenAIProvider: built lazily so an engine can be
|
||||
# assembled before any key exists; key resolves at call time (explicit → env →
|
||||
# SecretStore). Tests inject a `client` directly. No base_url — a custom endpoint
|
||||
# routes to the Chat Completions provider instead (registry.py).
|
||||
self._client = client
|
||||
self._api_key = api_key
|
||||
self._secrets = secrets
|
||||
self.default_model = default_model
|
||||
|
||||
def _ensure_client(self) -> Any:
|
||||
if self._client is None:
|
||||
# Lazy import so the SDK is only required when actually talking to OpenAI.
|
||||
from openai import OpenAI
|
||||
|
||||
key = self._api_key or resolve_api_key(self._secrets)
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"No model API key configured. Set OPENAI_API_KEY in the environment, "
|
||||
"or add your key in Manage → Settings."
|
||||
)
|
||||
self._client = OpenAI(api_key=key)
|
||||
return self._client
|
||||
|
||||
def _request_kwargs(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]],
|
||||
settings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
instructions, items = convert_messages(messages)
|
||||
if "max_tokens" in settings and "max_output_tokens" not in settings:
|
||||
settings = {**settings, "max_output_tokens": settings["max_tokens"]}
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"input": items,
|
||||
# Stateless: nothing retained server-side; the encrypted reasoning rides the
|
||||
# `_openai` sidecar instead, and summaries feed the GUI's thinking display.
|
||||
"store": False,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"summary": "auto"},
|
||||
**{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
|
||||
}
|
||||
if instructions:
|
||||
kwargs["instructions"] = instructions
|
||||
if tools:
|
||||
converted = convert_tools(tools)
|
||||
if converted:
|
||||
kwargs["tools"] = converted
|
||||
return kwargs
|
||||
|
||||
def _create(self, client: Any, kwargs: dict[str, Any]) -> Any:
|
||||
# Up to three param-fix retries: sampling params, `reasoning`, and `include` can
|
||||
# each need dropping depending on the model (reasoning vs not).
|
||||
for _ in range(3):
|
||||
try:
|
||||
return client.responses.create(**kwargs)
|
||||
except Exception as exc:
|
||||
kwargs = _param_fix_retry(kwargs, exc)
|
||||
return client.responses.create(**kwargs)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
**settings: Any,
|
||||
) -> AssistantTurn:
|
||||
kwargs = self._request_kwargs(
|
||||
model=model, messages=messages, tools=tools, settings=settings
|
||||
)
|
||||
response = self._create(self._ensure_client(), kwargs)
|
||||
return _parse_response(response)
|
||||
|
||||
def capabilities(self, model: str) -> ModelCapabilities:
|
||||
return capabilities_for(model)
|
||||
|
||||
def stream(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
**settings: Any,
|
||||
):
|
||||
kwargs = self._request_kwargs(
|
||||
model=model, messages=messages, tools=tools, settings=settings
|
||||
)
|
||||
kwargs["stream"] = True
|
||||
events = self._create(self._ensure_client(), kwargs)
|
||||
|
||||
text_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
final: Optional[Any] = None
|
||||
for event in events:
|
||||
kind = getattr(event, "type", None)
|
||||
if kind == "response.output_text.delta":
|
||||
delta = getattr(event, "delta", None)
|
||||
if delta:
|
||||
text_parts.append(delta)
|
||||
yield StreamChunk(text_delta=delta)
|
||||
elif kind == "response.reasoning_summary_text.delta":
|
||||
delta = getattr(event, "delta", None)
|
||||
if delta:
|
||||
reasoning_parts.append(delta)
|
||||
yield StreamChunk(reasoning_delta=delta)
|
||||
elif kind in ("response.completed", "response.incomplete", "response.failed"):
|
||||
final = getattr(event, "response", None)
|
||||
|
||||
if final is not None:
|
||||
# The terminal event carries the full response — parse it whole so tool
|
||||
# calls, finish reason, and the `_openai` sidecar come from one place.
|
||||
yield StreamChunk(turn=_parse_response(final))
|
||||
else:
|
||||
yield StreamChunk(
|
||||
turn=AssistantTurn(
|
||||
text="".join(text_parts) or None,
|
||||
reasoning="".join(reasoning_parts) or None,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,558 @@
|
||||
"""OpenAI Responses provider — message/tool conversion, complete(), stream(), sidecar
|
||||
replay, param-fix retries. SDK-free: the fake client mimics the OpenAI SDK's
|
||||
`responses.create` surface with dicts/SimpleNamespace objects, the same pattern the
|
||||
Gemini/Anthropic provider tests use."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from coworker.providers.openai_responses import (
|
||||
OpenAIResponsesProvider,
|
||||
_param_fix_retry,
|
||||
convert_messages,
|
||||
convert_tools,
|
||||
)
|
||||
|
||||
# -- fakes ------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Records the kwargs passed to responses.create; raises queued errors first (to
|
||||
exercise the param-fix retries), then returns the canned response — or, when the
|
||||
request asked for stream=True, an iterator of canned events."""
|
||||
|
||||
def __init__(self, response=None, events=None, errors=None):
|
||||
self.kwargs: dict = {}
|
||||
self.calls: list[dict] = []
|
||||
errors = list(errors or [])
|
||||
|
||||
def create(**kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.calls.append(kwargs)
|
||||
if errors:
|
||||
raise errors.pop(0)
|
||||
if kwargs.get("stream"):
|
||||
return iter(events or [])
|
||||
return response
|
||||
|
||||
self.responses = SimpleNamespace(create=create)
|
||||
|
||||
|
||||
def _response(output, status="completed", incomplete_details=None):
|
||||
return SimpleNamespace(
|
||||
output=output, status=status, incomplete_details=incomplete_details
|
||||
)
|
||||
|
||||
|
||||
def _message_item(text):
|
||||
return {
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
}
|
||||
|
||||
|
||||
def _reasoning_item(summaries, encrypted="enc-blob"):
|
||||
item = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [{"type": "summary_text", "text": s} for s in summaries],
|
||||
}
|
||||
if encrypted:
|
||||
item["encrypted_content"] = encrypted
|
||||
return item
|
||||
|
||||
|
||||
def _call_item(call_id, name, arguments):
|
||||
return {
|
||||
"type": "function_call",
|
||||
"id": f"fc_{call_id}",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
|
||||
# -- message conversion -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_convert_extracts_leading_system_as_instructions():
|
||||
instructions, items = convert_messages(
|
||||
[
|
||||
{"role": "system", "content": "be helpful"},
|
||||
{"role": "system", "content": "be brief"},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
)
|
||||
assert instructions == "be helpful\n\nbe brief"
|
||||
assert items == [{"role": "user", "content": "hi"}]
|
||||
|
||||
|
||||
def test_convert_mid_thread_system_stays_a_message():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "system", "content": "steering"},
|
||||
]
|
||||
)
|
||||
assert items[1] == {"role": "system", "content": "steering"}
|
||||
|
||||
|
||||
def test_convert_user_parts_to_input_parts():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "what is this"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo="},
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"file": {
|
||||
"filename": "report.pdf",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0=",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert items[0]["content"] == [
|
||||
{"type": "input_text", "text": "what is this"},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="},
|
||||
{
|
||||
"type": "input_file",
|
||||
"filename": "report.pdf",
|
||||
"file_data": "data:application/pdf;base64,JVBERi0=",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_convert_synthesizes_assistant_and_tool_items():
|
||||
# No `_openai` sidecar (history from another provider): items are rebuilt from the
|
||||
# canonical fields, and foreign toolu_ ids still pair call → output.
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "on it",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_abc",
|
||||
"type": "function",
|
||||
"function": {"name": "f", "arguments": '{"x": 1}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_abc", "content": '{"ok": true}'},
|
||||
]
|
||||
)
|
||||
assert items[1] == {"role": "assistant", "content": "on it"}
|
||||
assert items[2] == {
|
||||
"type": "function_call",
|
||||
"call_id": "toolu_abc",
|
||||
"name": "f",
|
||||
"arguments": '{"x": 1}',
|
||||
}
|
||||
assert items[3] == {
|
||||
"type": "function_call_output",
|
||||
"call_id": "toolu_abc",
|
||||
"output": '{"ok": true}',
|
||||
}
|
||||
|
||||
|
||||
def test_convert_replays_openai_sidecar_verbatim():
|
||||
sidecar_items = [
|
||||
_reasoning_item(["thinking"], encrypted="blob"),
|
||||
_message_item("on it"),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "on it",
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "function": {"name": "f", "arguments": "{}"}}
|
||||
],
|
||||
"_openai": {"items": sidecar_items},
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "done"},
|
||||
]
|
||||
)
|
||||
# The sidecar items go in verbatim — no synthesized duplicates alongside.
|
||||
assert items[1:4] == sidecar_items
|
||||
assert items[4]["type"] == "function_call_output"
|
||||
|
||||
|
||||
def test_convert_ignores_foreign_sidecars():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "hi",
|
||||
"_gemini": {"text_sig": "abc"},
|
||||
},
|
||||
]
|
||||
)
|
||||
assert items[1] == {"role": "assistant", "content": "hi"}
|
||||
|
||||
|
||||
def test_convert_empty_assistant_tool_turn_emits_no_message_item():
|
||||
_, items = convert_messages(
|
||||
[
|
||||
{"role": "user", "content": "go"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "function": {"name": "f", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
assert [i.get("type") for i in items[1:]] == ["function_call"]
|
||||
|
||||
|
||||
# -- tool schema conversion ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_convert_tools_flattens_function_schemas():
|
||||
tools = convert_tools(
|
||||
[
|
||||
{"type": "function", "function": {"name": "bare"}},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "full",
|
||||
"description": "does things",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
assert tools[0] == {"type": "function", "name": "bare"}
|
||||
assert tools[1]["name"] == "full" and "function" not in tools[1]
|
||||
assert tools[1]["parameters"]["properties"] == {"x": {"type": "integer"}}
|
||||
assert convert_tools(None) == []
|
||||
|
||||
|
||||
# -- complete() ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_complete_text_turn_and_request_shape():
|
||||
fake = _FakeClient(response=_response([_message_item("hello")]))
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
turn = provider.complete(
|
||||
model="gpt-5.6-sol",
|
||||
messages=[
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
)
|
||||
assert turn.text == "hello" and turn.finish_reason == "stop"
|
||||
assert not turn.has_tool_calls and turn.extras == {}
|
||||
assert fake.kwargs["model"] == "gpt-5.6-sol"
|
||||
assert fake.kwargs["instructions"] == "sys"
|
||||
assert fake.kwargs["store"] is False
|
||||
assert fake.kwargs["include"] == ["reasoning.encrypted_content"]
|
||||
assert fake.kwargs["reasoning"] == {"summary": "auto"}
|
||||
|
||||
|
||||
def test_complete_parses_function_calls_with_call_ids():
|
||||
fake = _FakeClient(
|
||||
response=_response(
|
||||
[
|
||||
_message_item("on it"),
|
||||
_call_item("call_a", "write_file", '{"path": "a.txt"}'),
|
||||
_call_item("call_b", "read_file", "not json"),
|
||||
]
|
||||
)
|
||||
)
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
|
||||
assert turn.text == "on it" and turn.finish_reason == "tool_calls"
|
||||
assert [(c.id, c.name) for c in turn.tool_calls] == [
|
||||
("call_a", "write_file"),
|
||||
("call_b", "read_file"),
|
||||
]
|
||||
assert turn.tool_calls[0].arguments == {"path": "a.txt"}
|
||||
assert turn.tool_calls[1].arguments == {"_raw": "not json"}
|
||||
|
||||
|
||||
def test_complete_surfaces_reasoning_summary_and_sidecar():
|
||||
items = [
|
||||
_reasoning_item(["plan a", " then b"], encrypted="blob"),
|
||||
_message_item("answer"),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.reasoning == "plan a then b"
|
||||
assert turn.extras["_openai"]["items"] == items
|
||||
|
||||
|
||||
def test_complete_drops_unresolvable_reasoning_from_sidecar():
|
||||
# No encrypted_content (e.g. `include` got param-fix-dropped): replaying the item
|
||||
# under store:false would 400, so it must not enter the sidecar.
|
||||
items = [
|
||||
_reasoning_item(["hmm"], encrypted=None),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.reasoning == "hmm" # still displayed…
|
||||
kinds = [i["type"] for i in turn.extras["_openai"]["items"]]
|
||||
assert kinds == ["function_call"] # …but never replayed
|
||||
|
||||
|
||||
def test_complete_plain_text_has_no_sidecar():
|
||||
provider = OpenAIResponsesProvider(
|
||||
client=_FakeClient(response=_response([_message_item("plain")]))
|
||||
)
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.extras == {}
|
||||
|
||||
|
||||
def test_complete_maps_incomplete_max_tokens_to_length():
|
||||
provider = OpenAIResponsesProvider(
|
||||
client=_FakeClient(
|
||||
response=_response(
|
||||
[_message_item("truncat")],
|
||||
status="incomplete",
|
||||
incomplete_details=SimpleNamespace(reason="max_output_tokens"),
|
||||
)
|
||||
)
|
||||
)
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.finish_reason == "length"
|
||||
|
||||
|
||||
def test_complete_filters_and_aliases_settings():
|
||||
fake = _FakeClient(response=_response([_message_item("x")]))
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
provider.complete(
|
||||
model="m",
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
temperature=0.2,
|
||||
max_tokens=512, # chat alias → max_output_tokens
|
||||
frequency_penalty=0.5, # not a Responses param → dropped
|
||||
reasoning_effort="high", # no effort knob in v1 → dropped
|
||||
)
|
||||
assert fake.kwargs["temperature"] == 0.2
|
||||
assert fake.kwargs["max_output_tokens"] == 512
|
||||
assert "max_tokens" not in fake.kwargs
|
||||
assert "frequency_penalty" not in fake.kwargs
|
||||
assert "reasoning_effort" not in fake.kwargs
|
||||
|
||||
|
||||
def test_complete_passes_flat_tools():
|
||||
fake = _FakeClient(response=_response([_message_item("x")]))
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
provider.complete(
|
||||
model="m",
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
tools=[{"type": "function", "function": {"name": "f"}}],
|
||||
)
|
||||
assert fake.kwargs["tools"] == [{"type": "function", "name": "f"}]
|
||||
|
||||
|
||||
def test_complete_parses_attr_style_sdk_objects():
|
||||
# The real SDK returns typed objects, not dicts — the parser must getattr its way in.
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
id="msg_1",
|
||||
role="assistant",
|
||||
content=[SimpleNamespace(type="output_text", text="hi", annotations=None)],
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="function_call",
|
||||
id="fc_1",
|
||||
call_id="call_1",
|
||||
name="f",
|
||||
arguments='{"a": 1}',
|
||||
),
|
||||
],
|
||||
status="completed",
|
||||
incomplete_details=None,
|
||||
)
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=response))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.text == "hi"
|
||||
assert turn.tool_calls[0].id == "call_1"
|
||||
assert turn.tool_calls[0].arguments == {"a": 1}
|
||||
|
||||
|
||||
# -- param-fix retries ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_param_fix_drops_named_parameter():
|
||||
kwargs = {"model": "m", "input": [], "temperature": 0.2}
|
||||
fixed = _param_fix_retry(
|
||||
kwargs, Exception("Unsupported parameter: 'temperature' is not supported")
|
||||
)
|
||||
assert "temperature" not in fixed and kwargs["temperature"] == 0.2 # copy, not mutate
|
||||
|
||||
|
||||
def test_param_fix_dotted_name_drops_top_level():
|
||||
fixed = _param_fix_retry(
|
||||
{"model": "m", "input": [], "reasoning": {"summary": "auto"}},
|
||||
Exception("Unsupported parameter: 'reasoning.summary'"),
|
||||
)
|
||||
assert "reasoning" not in fixed
|
||||
|
||||
|
||||
def test_param_fix_reraises_unknown_errors():
|
||||
with pytest.raises(Exception, match="rate limit"):
|
||||
_param_fix_retry({"model": "m", "input": []}, Exception("rate limit exceeded"))
|
||||
|
||||
|
||||
def test_complete_retries_dropping_rejected_params():
|
||||
# A non-reasoning model rejecting `reasoning` then `include` — both retried away.
|
||||
fake = _FakeClient(
|
||||
response=_response([_message_item("ok")]),
|
||||
errors=[
|
||||
Exception("Unsupported parameter: 'reasoning'"),
|
||||
Exception("Unsupported value: 'include[0]'"),
|
||||
],
|
||||
)
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
turn = provider.complete(model="gpt-4.1", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.text == "ok"
|
||||
assert len(fake.calls) == 3
|
||||
assert "reasoning" not in fake.kwargs and "include" not in fake.kwargs
|
||||
|
||||
|
||||
# -- stream() ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stream_yields_deltas_then_final_turn_from_completed_event():
|
||||
final = _response(
|
||||
[
|
||||
_reasoning_item(["mull it over"], encrypted="blob"),
|
||||
_message_item("hello"),
|
||||
]
|
||||
)
|
||||
events = [
|
||||
SimpleNamespace(type="response.created"),
|
||||
SimpleNamespace(type="response.reasoning_summary_text.delta", delta="mull "),
|
||||
SimpleNamespace(type="response.reasoning_summary_text.delta", delta="it over"),
|
||||
SimpleNamespace(type="response.output_text.delta", delta="hel"),
|
||||
SimpleNamespace(type="response.output_text.delta", delta="lo"),
|
||||
SimpleNamespace(type="response.completed", response=final),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
|
||||
out = list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
|
||||
assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["mull ", "it over"]
|
||||
assert [c.text_delta for c in out if c.text_delta] == ["hel", "lo"]
|
||||
turn = out[-1].turn
|
||||
assert turn.text == "hello" and turn.reasoning == "mull it over"
|
||||
assert turn.finish_reason == "stop"
|
||||
# Encrypted reasoning is replay-worthy even without function calls.
|
||||
assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
|
||||
"reasoning",
|
||||
"message",
|
||||
]
|
||||
|
||||
|
||||
def test_stream_final_turn_carries_tool_calls_and_sidecar():
|
||||
final = _response(
|
||||
[
|
||||
_reasoning_item(["plan"], encrypted="blob"),
|
||||
_call_item("call_1", "f", '{"x": 1}'),
|
||||
]
|
||||
)
|
||||
events = [SimpleNamespace(type="response.completed", response=final)]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
|
||||
turn = list(
|
||||
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
)[-1].turn
|
||||
assert turn.finish_reason == "tool_calls"
|
||||
assert turn.tool_calls[0].arguments == {"x": 1}
|
||||
assert [i["type"] for i in turn.extras["_openai"]["items"]] == [
|
||||
"reasoning",
|
||||
"function_call",
|
||||
]
|
||||
|
||||
|
||||
def test_stream_without_terminal_event_keeps_accumulated_text():
|
||||
events = [SimpleNamespace(type="response.output_text.delta", delta="partial")]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(events=events))
|
||||
turn = list(
|
||||
provider.stream(model="m", messages=[{"role": "user", "content": "x"}])
|
||||
)[-1].turn
|
||||
assert turn.text == "partial" and turn.finish_reason is None
|
||||
|
||||
|
||||
def test_stream_requests_stream_flag():
|
||||
fake = _FakeClient(events=[])
|
||||
provider = OpenAIResponsesProvider(client=fake)
|
||||
list(provider.stream(model="m", messages=[{"role": "user", "content": "x"}]))
|
||||
assert fake.kwargs["stream"] is True
|
||||
|
||||
|
||||
# -- round trip ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sidecar_round_trip_replays_what_complete_stored():
|
||||
"""A tool loop: turn 1's sidecar items must be exactly what turn 2's request replays."""
|
||||
items = [
|
||||
_reasoning_item(["plan"], encrypted="blob"),
|
||||
_call_item("call_1", "f", "{}"),
|
||||
]
|
||||
provider = OpenAIResponsesProvider(client=_FakeClient(response=_response(items)))
|
||||
turn = provider.complete(model="m", messages=[{"role": "user", "content": "go"}])
|
||||
|
||||
# The engine persists canonical fields + extras (engine._assistant_message):
|
||||
assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": turn.text or "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
|
||||
}
|
||||
for tc in turn.tool_calls
|
||||
],
|
||||
**turn.extras,
|
||||
}
|
||||
fake2 = _FakeClient(response=_response([_message_item("done")]))
|
||||
provider2 = OpenAIResponsesProvider(client=fake2)
|
||||
provider2.complete(
|
||||
model="m",
|
||||
messages=[
|
||||
{"role": "user", "content": "go"},
|
||||
assistant_message,
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
|
||||
],
|
||||
)
|
||||
sent = fake2.kwargs["input"]
|
||||
assert sent[1:3] == items # replayed verbatim, reasoning first
|
||||
assert sent[3] == {
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "ok",
|
||||
}
|
||||
|
||||
|
||||
def test_ensure_client_without_key_raises(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with pytest.raises(RuntimeError, match="No model API key"):
|
||||
OpenAIResponsesProvider()._ensure_client()
|
||||
Reference in New Issue
Block a user