Files
openworker/coworker/providers/openai_responses.py
T
Rohit C Prasad 87b4eebc15 Merge main into feature/permission-modes
Union resolutions in the four files both sides touched; approval-card sizes settle on the type-scale tokens.
2026-08-23 01:01:57 -07:00

463 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)
)