mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
Union resolutions in the four files both sides touched; approval-card sizes settle on the type-scale tokens.
463 lines
19 KiB
Python
463 lines
19 KiB
Python
"""OpenAI Responses provider — native and compatible models via `/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. Most custom
|
||
endpoints (Azure, vLLM, and the existing compat vendors) keep the Chat Completions
|
||
`OpenAIProvider`; vendors that explicitly implement the Responses wire can opt into this
|
||
class with their own base URL (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,
|
||
TokenUsage,
|
||
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 _usage_from(usage: Any) -> Optional[TokenUsage]:
|
||
"""Responses-API usage → normalized counts (OPE-101). `input_tokens` INCLUDES the
|
||
cached share, so fresh input = input_tokens − cached_tokens (the same convention as
|
||
the Chat Completions and Anthropic adapters); `output_tokens` already includes
|
||
reasoning tokens (billed as output). Defensive reads throughout — compat/older
|
||
servers may omit `input_tokens_details`."""
|
||
if usage is None:
|
||
return None
|
||
prompt = int(getattr(usage, "input_tokens", 0) or 0)
|
||
details = getattr(usage, "input_tokens_details", None)
|
||
cached = int(getattr(details, "cached_tokens", 0) or 0)
|
||
return TokenUsage(
|
||
input=max(prompt - cached, 0),
|
||
output=int(getattr(usage, "output_tokens", 0) or 0),
|
||
cache_read=cached,
|
||
)
|
||
|
||
|
||
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),
|
||
usage=_usage_from(getattr(response, "usage", None)),
|
||
)
|
||
|
||
|
||
class OpenAIResponsesProvider(ProviderClient):
|
||
def __init__(
|
||
self,
|
||
client: Any = None,
|
||
*,
|
||
default_model: str = "gpt-5.6-sol",
|
||
api_key: Optional[str] = None,
|
||
secrets: Any = None,
|
||
base_url: Optional[str] = None,
|
||
reasoning_summary: bool = True,
|
||
):
|
||
# 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. `base_url` is opt-in: stock OpenAI
|
||
# leaves it unset, while Responses-compatible vendors can supply their own endpoint.
|
||
self._client = client
|
||
self._api_key = api_key
|
||
self._secrets = secrets
|
||
self._base_url = (base_url or "").strip().rstrip("/") or None
|
||
if not isinstance(reasoning_summary, bool):
|
||
raise TypeError("reasoning_summary must be a bool")
|
||
self._reasoning_summary = reasoning_summary
|
||
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."
|
||
)
|
||
kwargs = {"api_key": key}
|
||
if self._base_url:
|
||
kwargs["base_url"] = self._base_url
|
||
self._client = OpenAI(**kwargs)
|
||
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"],
|
||
**{k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST},
|
||
}
|
||
if self._reasoning_summary:
|
||
kwargs["reasoning"] = {"summary": "auto"}
|
||
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] = []
|
||
done_items: list[Any] = []
|
||
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 == "response.output_item.done":
|
||
item = getattr(event, "item", None)
|
||
if item is not None:
|
||
done_items.append(item)
|
||
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.
|
||
# Some Responses-compatible backends (the subscription backend) leave the
|
||
# terminal response's `output` EMPTY — the items only ever stream — so
|
||
# graft the streamed output_item.done items back on before parsing, or a
|
||
# turn's text and tool calls silently vanish.
|
||
if not (getattr(final, "output", None) or []) and done_items:
|
||
try:
|
||
final.output = done_items
|
||
except Exception:
|
||
pass
|
||
turn = _parse_response(final)
|
||
if turn.text is None and not turn.tool_calls and text_parts:
|
||
turn.text = "".join(text_parts)
|
||
yield StreamChunk(turn=turn)
|
||
else:
|
||
yield StreamChunk(
|
||
turn=AssistantTurn(
|
||
text="".join(text_parts) or None,
|
||
reasoning="".join(reasoning_parts) or None,
|
||
)
|
||
)
|