169 lines
9.0 KiB
Python
169 lines
9.0 KiB
Python
"""Native /responses adapter; stateless history uses function_call/output items."""
|
|
|
|
import json
|
|
from contextlib import aclosing
|
|
|
|
from app.contracts import MessageRole, ModelEventType, ModelRequest
|
|
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
|
from app.providers.http_base import (
|
|
UsageTracker, check_error, decode_tool_arguments, invalid_response, list_value,
|
|
object_value, remote_error, string_value, token_count, truncated_stream,
|
|
)
|
|
from app.providers.openai_compatible import OpenAICompatibleProvider
|
|
from app.providers.tool_names import mapped_tool_names
|
|
|
|
|
|
class OpenAIResponsesProvider(OpenAICompatibleProvider):
|
|
stream_path = "/responses"
|
|
|
|
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
|
inputs = []
|
|
for message in request.messages:
|
|
if message.role == MessageRole.tool:
|
|
if not message.tool_call_id:
|
|
raise ProviderError("PROVIDER_INVALID_REQUEST", "Tool result requires a call identifier.")
|
|
inputs.append({"type": "function_call_output", "call_id": message.tool_call_id,
|
|
"output": message.content})
|
|
continue
|
|
if message.content or not message.tool_calls:
|
|
inputs.append({"role": message.role.value, "content": message.content})
|
|
for call in message.tool_calls:
|
|
inputs.append({"type": "function_call", "call_id": call.tool_call_id,
|
|
"name": call.name, "arguments": json.dumps(call.arguments)})
|
|
payload: dict[str, object] = {"model": request.model, "input": inputs, "stream": stream}
|
|
if request.system:
|
|
payload["instructions"] = request.system
|
|
if request.tools:
|
|
payload["tools"] = [{"type": "function", "name": tool.name,
|
|
"description": tool.description, "parameters": tool.parameters}
|
|
for tool in request.tools]
|
|
if request.temperature is not None:
|
|
payload["temperature"] = request.temperature
|
|
if request.max_tokens is not None:
|
|
payload["max_output_tokens"] = request.max_tokens
|
|
if request.response_format is not None:
|
|
format_ = dict(request.response_format)
|
|
if format_.get("type") == "json_schema":
|
|
format_ = {"type": "json_schema", **object_value(format_.get("json_schema"))}
|
|
payload["text"] = {"format": format_}
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _check_response(data: dict) -> None:
|
|
check_error(data)
|
|
status = data.get("status")
|
|
if status == "incomplete":
|
|
raise ProviderError("PROVIDER_INCOMPLETE_RESPONSE", "Provider response is incomplete.")
|
|
if status == "failed":
|
|
raise remote_error(data.get("error"))
|
|
if status is not None and status != "completed":
|
|
raise invalid_response()
|
|
|
|
@mapped_tool_names
|
|
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
|
data = await self._request("POST", self.stream_path, json=self._payload(request, stream=False))
|
|
self._check_response(data)
|
|
texts = []
|
|
calls = []
|
|
for raw in list_value(data.get("output")):
|
|
item = object_value(raw)
|
|
if item.get("type") == "message":
|
|
for raw_part in list_value(item.get("content")):
|
|
part = object_value(raw_part)
|
|
if part.get("type") == "output_text":
|
|
texts.append(string_value(part.get("text")))
|
|
elif part.get("type") == "refusal":
|
|
texts.append(string_value(part.get("refusal")))
|
|
elif item.get("type") == "function_call":
|
|
calls.append(ProviderToolCall(
|
|
tool_call_id=string_value(item.get("call_id"), nonempty=True),
|
|
name=string_value(item.get("name"), nonempty=True),
|
|
arguments=decode_tool_arguments(item.get("arguments")),
|
|
))
|
|
return ProviderTurn(text="".join(texts) or None, tool_calls=calls,
|
|
**UsageTracker().update(data.get("usage") or {}))
|
|
|
|
async def _events(self, request: ModelRequest):
|
|
calls: dict[int, dict] = {}
|
|
usage = UsageTracker()
|
|
|
|
def finish_call(index: int, final: object = None):
|
|
call = calls[index]
|
|
if call["ended"]:
|
|
return []
|
|
events = []
|
|
if final is not None:
|
|
arguments = string_value(final)
|
|
if not arguments.startswith(call["arguments"]):
|
|
raise invalid_response()
|
|
remainder = arguments[len(call["arguments"]):]
|
|
if remainder:
|
|
events.append((ModelEventType.tool_call_delta,
|
|
{"tool_call_id": call["id"], "arguments_delta": remainder}))
|
|
call["arguments"] = arguments
|
|
decode_tool_arguments(call["arguments"])
|
|
call["ended"] = True
|
|
events.append((ModelEventType.tool_call_end, {"tool_call_id": call["id"]}))
|
|
return events
|
|
|
|
async with aclosing(self._stream_json(self._payload(request, stream=True))) as chunks:
|
|
async for data in chunks:
|
|
kind = string_value(data.get("type"), nonempty=True)
|
|
if kind in {"response.failed", "response.incomplete"}:
|
|
response = object_value(data.get("response"))
|
|
self._check_response({**response, "status": kind.split(".")[1]})
|
|
elif kind in {"response.output_text.delta", "response.refusal.delta"}:
|
|
yield ModelEventType.text_delta, {"text": string_value(data.get("delta"))}
|
|
elif kind in {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}:
|
|
yield ModelEventType.thinking_delta, {"text": string_value(data.get("delta"))}
|
|
elif kind in {"response.output_item.added", "response.output_item.done"}:
|
|
item = object_value(data.get("item"))
|
|
if item.get("type") != "function_call":
|
|
continue
|
|
index = token_count(data.get("output_index"))
|
|
call_id = string_value(item.get("call_id"), nonempty=True)
|
|
name = string_value(item.get("name"), nonempty=True)
|
|
if index not in calls:
|
|
calls[index] = {"id": call_id, "name": name, "arguments": "", "ended": False,
|
|
"item_id": item.get("id")}
|
|
yield ModelEventType.tool_call_start, {"tool_call_id": call_id, "name": name}
|
|
elif calls[index]["id"] != call_id or calls[index]["name"] != name:
|
|
raise invalid_response()
|
|
if kind == "response.output_item.done":
|
|
for event in finish_call(index, item.get("arguments")):
|
|
yield event
|
|
elif item.get("arguments"):
|
|
arguments = string_value(item["arguments"])
|
|
calls[index]["arguments"] += arguments
|
|
yield ModelEventType.tool_call_delta, {"tool_call_id": call_id, "arguments_delta": arguments}
|
|
elif kind in {"response.function_call_arguments.delta", "response.function_call_arguments.done"}:
|
|
index = token_count(data.get("output_index"))
|
|
call = calls.get(index)
|
|
if call is None or (data.get("item_id") and call["item_id"] != data["item_id"]):
|
|
raise invalid_response()
|
|
if kind.endswith(".done"):
|
|
for event in finish_call(index, data.get("arguments")):
|
|
yield event
|
|
else:
|
|
if call["ended"]:
|
|
raise invalid_response()
|
|
fragment = string_value(data.get("delta"))
|
|
call["arguments"] += fragment
|
|
yield ModelEventType.tool_call_delta, {"tool_call_id": call["id"], "arguments_delta": fragment}
|
|
elif kind == "response.completed":
|
|
response = object_value(data.get("response"))
|
|
self._check_response(response)
|
|
if any(not call["ended"] for call in calls.values()):
|
|
raise truncated_stream()
|
|
if response.get("usage") is not None:
|
|
yield ModelEventType.usage, usage.update(response["usage"])
|
|
return
|
|
elif kind == "[DONE]":
|
|
raise truncated_stream()
|
|
elif kind in {"response.created", "response.in_progress"}:
|
|
response = object_value(data.get("response"))
|
|
check_error(response)
|
|
if response.get("usage") is not None:
|
|
yield ModelEventType.usage, usage.update(response["usage"])
|
|
raise truncated_stream()
|