feat: harden public OpenAI API contracts
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from db.models import OpenAIResponse
|
||||
from services.openai_api import error_payload as public_error_payload
|
||||
|
||||
|
||||
LOCKED_FIELDS = {"instructions", "tools", "tool_choice"}
|
||||
@@ -54,26 +56,26 @@ class ResponseRequest:
|
||||
|
||||
|
||||
def error_payload(error: ResponsesAPIError) -> dict[str, Any]:
|
||||
return {
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
"message": error.message,
|
||||
"param": error.param,
|
||||
}
|
||||
}
|
||||
return public_error_payload(
|
||||
error.message,
|
||||
code=error.code,
|
||||
param=error.param,
|
||||
)
|
||||
|
||||
|
||||
def _text_content(value: object, *, role: str) -> list[dict[str, str]]:
|
||||
def _text_content(value: object, *, role: str) -> list[dict[str, Any]]:
|
||||
expected = "input_text" if role == "user" else "output_text"
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ResponsesAPIError("Message content cannot be empty", param="input")
|
||||
return [{"type": expected, "text": text}]
|
||||
item: dict[str, Any] = {"type": expected, "text": text}
|
||||
if expected == "output_text":
|
||||
item["annotations"] = []
|
||||
return [item]
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ResponsesAPIError("Message content must be text", param="input")
|
||||
result: list[dict[str, str]] = []
|
||||
result: list[dict[str, Any]] = []
|
||||
for part in value:
|
||||
if not isinstance(part, dict) or part.get("type") != expected:
|
||||
raise ResponsesAPIError(
|
||||
@@ -82,7 +84,10 @@ def _text_content(value: object, *, role: str) -> list[dict[str, str]]:
|
||||
text = str(part.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ResponsesAPIError("Message text cannot be empty", param="input")
|
||||
result.append({"type": expected, "text": text})
|
||||
item: dict[str, Any] = {"type": expected, "text": text}
|
||||
if expected == "output_text":
|
||||
item["annotations"] = []
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
@@ -90,9 +95,11 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
|
||||
if isinstance(value, str):
|
||||
return [
|
||||
{
|
||||
"id": new_id("msg"),
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": _text_content(value, role="user"),
|
||||
"status": "completed",
|
||||
}
|
||||
]
|
||||
if not isinstance(value, list) or not value:
|
||||
@@ -105,6 +112,13 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
|
||||
raise ResponsesAPIError("Input item must be an object", param=param)
|
||||
item_type = str(raw.get("type") or "message")
|
||||
if item_type == "message":
|
||||
unknown_fields = sorted(set(raw) - {"type", "role", "content"})
|
||||
if unknown_fields:
|
||||
raise ResponsesAPIError(
|
||||
f"Unsupported message field: {unknown_fields[0]}",
|
||||
code="unsupported_parameter",
|
||||
param=f"{param}.{unknown_fields[0]}",
|
||||
)
|
||||
role = str(raw.get("role") or "user")
|
||||
if role not in {"user", "assistant"}:
|
||||
raise ResponsesAPIError(
|
||||
@@ -112,13 +126,22 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"id": new_id("msg"),
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": _text_content(raw.get("content"), role=role),
|
||||
"status": "completed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if item_type == "function_call_output":
|
||||
unknown_fields = sorted(set(raw) - {"type", "call_id", "output"})
|
||||
if unknown_fields:
|
||||
raise ResponsesAPIError(
|
||||
f"Unsupported function_call_output field: {unknown_fields[0]}",
|
||||
code="unsupported_parameter",
|
||||
param=f"{param}.{unknown_fields[0]}",
|
||||
)
|
||||
call_id = str(raw.get("call_id") or "").strip()
|
||||
if not call_id:
|
||||
raise ResponsesAPIError(
|
||||
@@ -130,9 +153,11 @@ def normalize_input(value: object) -> list[dict[str, Any]]:
|
||||
output = json.dumps(output, ensure_ascii=False, default=str)
|
||||
items.append(
|
||||
{
|
||||
"id": new_id("fc"),
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output,
|
||||
"status": "completed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -188,6 +213,20 @@ def parse_request(body: object) -> ResponseRequest:
|
||||
"variables must be an object",
|
||||
param="x_interactive_media.variables",
|
||||
)
|
||||
unknown_extensions = sorted(set(extension) - {"version", "variables"})
|
||||
if unknown_extensions:
|
||||
raise ResponsesAPIError(
|
||||
f"Unsupported extension parameter: {unknown_extensions[0]}",
|
||||
code="unsupported_parameter",
|
||||
param=f"x_interactive_media.{unknown_extensions[0]}",
|
||||
)
|
||||
extension_version = extension.get("version")
|
||||
if extension_version not in (None, "1"):
|
||||
raise ResponsesAPIError(
|
||||
"x_interactive_media.version must be 1",
|
||||
code="unsupported_extension_version",
|
||||
param="x_interactive_media.version",
|
||||
)
|
||||
raw_model = body.get("model")
|
||||
if raw_model is not None and not isinstance(raw_model, str):
|
||||
raise ResponsesAPIError("model must be a string", param="model")
|
||||
@@ -217,6 +256,38 @@ def new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid4().hex}"
|
||||
|
||||
|
||||
def request_fingerprint(body: dict[str, Any]) -> str:
|
||||
"""Return a stable digest for Idempotency-Key conflict detection."""
|
||||
|
||||
canonical = json.dumps(
|
||||
body,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_idempotency_key(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
key = value.strip()
|
||||
if not key:
|
||||
raise ResponsesAPIError(
|
||||
"Idempotency-Key must not be empty",
|
||||
code="invalid_idempotency_key",
|
||||
param="Idempotency-Key",
|
||||
)
|
||||
if len(key) > 255:
|
||||
raise ResponsesAPIError(
|
||||
"Idempotency-Key must be at most 255 characters",
|
||||
code="invalid_idempotency_key",
|
||||
param="Idempotency-Key",
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def response_payload(row: OpenAIResponse) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
@@ -237,5 +308,5 @@ def response_payload(row: OpenAIResponse) -> dict[str, Any]:
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"metadata": {},
|
||||
"usage": None,
|
||||
"usage": getattr(row, "usage", None),
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ from pipecat.frames.frames import (
|
||||
LLMMessagesAppendFrame,
|
||||
LLMTextFrame,
|
||||
ManuallySwitchServiceFrame,
|
||||
MetricsFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.metrics.metrics import LLMTokenUsage, LLMUsageMetricsData
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
@@ -63,6 +65,19 @@ class ResponseRunError(RuntimeError):
|
||||
DeltaCallback = Callable[[str], Awaitable[None]]
|
||||
|
||||
|
||||
def _normalized_tool_arguments(value: object) -> object:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def _tool_arguments_equal(left: object, right: object) -> bool:
|
||||
return _normalized_tool_arguments(left) == _normalized_tool_arguments(right)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResponseRunResult:
|
||||
text: str
|
||||
@@ -70,6 +85,27 @@ class ResponseRunResult:
|
||||
context_messages: list[dict[str, Any]]
|
||||
dynamic_variables: dict[str, Any]
|
||||
runtime_state: dict[str, Any]
|
||||
usage: dict[str, Any] | None
|
||||
|
||||
|
||||
def _usage_payload(values: list[LLMTokenUsage]) -> dict[str, Any] | None:
|
||||
"""Combine Provider-reported LLM usage without estimating missing values."""
|
||||
|
||||
if not values:
|
||||
return None
|
||||
return {
|
||||
"input_tokens": sum(value.prompt_tokens for value in values),
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": sum(
|
||||
value.cache_read_input_tokens or 0 for value in values
|
||||
),
|
||||
},
|
||||
"output_tokens": sum(value.completion_tokens for value in values),
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": sum(value.reasoning_tokens or 0 for value in values),
|
||||
},
|
||||
"total_tokens": sum(value.total_tokens for value in values),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -101,6 +137,7 @@ class _CaptureProcessor(FrameProcessor):
|
||||
self.window = _ResponseWindow()
|
||||
self.deltas: list[str] = []
|
||||
self.outputs: list[str] = []
|
||||
self.llm_usage: list[LLMTokenUsage] = []
|
||||
self.error: str | None = None
|
||||
|
||||
def reset(self) -> None:
|
||||
@@ -143,6 +180,12 @@ class _CaptureProcessor(FrameProcessor):
|
||||
self.error = frame.error or "Pipeline execution failed"
|
||||
self.window.pending_tools.clear()
|
||||
self.window.touch()
|
||||
elif isinstance(frame, MetricsFrame):
|
||||
self.llm_usage.extend(
|
||||
metric.value
|
||||
for metric in frame.data
|
||||
if isinstance(metric, LLMUsageMetricsData)
|
||||
)
|
||||
elif isinstance(frame, _BoundaryFrame):
|
||||
frame.reached.set()
|
||||
self.window.touch()
|
||||
@@ -162,6 +205,13 @@ class ResponsesClientToolPort:
|
||||
def supports(self, _function_name: str) -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _arguments_match(pending: dict[str, Any], arguments: dict[str, Any]) -> bool:
|
||||
original = pending.get("arguments")
|
||||
if original is None:
|
||||
return True
|
||||
return _tool_arguments_equal(original, arguments)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
function_name: str,
|
||||
@@ -174,7 +224,10 @@ class ResponsesClientToolPort:
|
||||
) -> dict[str, Any]:
|
||||
del timeout_seconds, wait_for_response, response_wait_mode, interrupt_on_result
|
||||
for index, pending in enumerate(self._results):
|
||||
if pending.get("name") == function_name:
|
||||
if (
|
||||
pending.get("name") == function_name
|
||||
and self._arguments_match(pending, arguments)
|
||||
):
|
||||
self._results.pop(index)
|
||||
return dict(pending.get("result") or {})
|
||||
item = {
|
||||
@@ -207,18 +260,29 @@ def _context_snapshot(
|
||||
]
|
||||
if fallback_messages:
|
||||
messages.extend(deepcopy(fallback_messages))
|
||||
candidates = [
|
||||
tool_call
|
||||
for message in messages
|
||||
for tool_call in message.get("tool_calls") or []
|
||||
]
|
||||
used_candidates: set[int] = set()
|
||||
for public_call in function_calls:
|
||||
matched = False
|
||||
for message in reversed(messages):
|
||||
for tool_call in reversed(message.get("tool_calls") or []):
|
||||
function = tool_call.get("function") or {}
|
||||
if function.get("name") != public_call["name"]:
|
||||
continue
|
||||
tool_call["id"] = public_call["call_id"]
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
break
|
||||
for index, tool_call in enumerate(candidates):
|
||||
if index in used_candidates:
|
||||
continue
|
||||
function = tool_call.get("function") or {}
|
||||
if function.get("name") != public_call["name"]:
|
||||
continue
|
||||
if not _tool_arguments_equal(
|
||||
function.get("arguments"),
|
||||
public_call.get("arguments"),
|
||||
):
|
||||
continue
|
||||
tool_call["id"] = public_call["call_id"]
|
||||
used_candidates.add(index)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
messages.append(
|
||||
{
|
||||
@@ -368,7 +432,10 @@ class ResponsesTextRunner:
|
||||
)
|
||||
worker = PipelineWorker(
|
||||
pipeline,
|
||||
params=PipelineParams(enable_metrics=False),
|
||||
params=PipelineParams(
|
||||
enable_metrics=False,
|
||||
enable_usage_metrics=True,
|
||||
),
|
||||
enable_rtvi=False,
|
||||
enable_turn_tracking=False,
|
||||
idle_timeout_secs=None,
|
||||
@@ -562,6 +629,7 @@ class ResponsesTextRunner:
|
||||
),
|
||||
dynamic_variables=_public_variables(brain, cfg),
|
||||
runtime_state=_runtime_state(brain),
|
||||
usage=_usage_payload(capture.llm_usage),
|
||||
)
|
||||
capture.reset()
|
||||
|
||||
@@ -589,6 +657,7 @@ class ResponsesTextRunner:
|
||||
),
|
||||
dynamic_variables=_public_variables(brain, cfg),
|
||||
runtime_state=_runtime_state(brain),
|
||||
usage=_usage_payload(capture.llm_usage),
|
||||
)
|
||||
text = (capture.outputs[-1] if capture.outputs else "".join(capture.deltas)).strip()
|
||||
if not text:
|
||||
@@ -599,6 +668,7 @@ class ResponsesTextRunner:
|
||||
context_messages=_context_snapshot(context, function_calls=[]),
|
||||
dynamic_variables=_public_variables(brain, cfg),
|
||||
runtime_state=_runtime_state(brain),
|
||||
usage=_usage_payload(capture.llm_usage),
|
||||
)
|
||||
finally:
|
||||
if not worker.has_finished():
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Pydantic contract for the supported Responses API surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
ItemStatus = Literal["in_progress", "completed", "incomplete"]
|
||||
ResponseStatus = Literal[
|
||||
"in_progress",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"incomplete",
|
||||
]
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class InputText(_StrictModel):
|
||||
type: Literal["input_text"] = "input_text"
|
||||
text: str
|
||||
|
||||
|
||||
class OutputTextInput(_StrictModel):
|
||||
type: Literal["output_text"] = "output_text"
|
||||
text: str
|
||||
annotations: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
MessageContent = Annotated[
|
||||
InputText | OutputTextInput,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class ResponseInputMessage(_StrictModel):
|
||||
type: Literal["message"] = "message"
|
||||
role: Literal["user", "assistant"] = "user"
|
||||
content: str | list[MessageContent]
|
||||
|
||||
|
||||
class ResponseFunctionCallOutputInput(_StrictModel):
|
||||
type: Literal["function_call_output"] = "function_call_output"
|
||||
call_id: str
|
||||
output: Any
|
||||
|
||||
|
||||
ResponseInputItem = Annotated[
|
||||
ResponseInputMessage | ResponseFunctionCallOutputInput,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class InteractiveMediaRequestExtension(_StrictModel):
|
||||
version: Literal["1"] | None = Field(
|
||||
default=None,
|
||||
description="Interactive Media extension version. Omit or use `1`.",
|
||||
)
|
||||
variables: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ResponseCreateRequest(BaseModel):
|
||||
"""Supported request fields; extras are preserved for semantic rejection."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"model": "assistant:asst_xxx",
|
||||
"input": "请介绍一下这个产品",
|
||||
"store": True,
|
||||
},
|
||||
{
|
||||
"previous_response_id": "resp_xxx",
|
||||
"input": "继续说明",
|
||||
"stream": True,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
model: str | None = Field(
|
||||
default=None,
|
||||
examples=["assistant:asst_xxx"],
|
||||
description="Required on the first turn; inherited on later turns.",
|
||||
)
|
||||
previous_response_id: str | None = None
|
||||
input: str | list[ResponseInputItem]
|
||||
store: bool | None = Field(
|
||||
default=None,
|
||||
description="Stateful subset: omit this field or set it to true.",
|
||||
)
|
||||
stream: bool = False
|
||||
x_interactive_media: InteractiveMediaRequestExtension | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def default_message_discriminator(cls, value: Any) -> Any:
|
||||
"""Accept the official SDK's easy input message with omitted `type`."""
|
||||
|
||||
if not isinstance(value, dict) or not isinstance(value.get("input"), list):
|
||||
return value
|
||||
copied = dict(value)
|
||||
copied["input"] = [
|
||||
({"type": "message", **item} if isinstance(item, dict) and "type" not in item else item)
|
||||
for item in value["input"]
|
||||
]
|
||||
return copied
|
||||
|
||||
|
||||
class ResponseOutputText(_StrictModel):
|
||||
type: Literal["output_text"] = "output_text"
|
||||
text: str
|
||||
annotations: list[dict[str, Any]] = Field(default_factory=list)
|
||||
logprobs: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class ResponseOutputMessage(_StrictModel):
|
||||
id: str
|
||||
type: Literal["message"] = "message"
|
||||
status: ItemStatus
|
||||
role: Literal["assistant"] = "assistant"
|
||||
content: list[ResponseOutputText]
|
||||
|
||||
|
||||
class ResponseFunctionCall(_StrictModel):
|
||||
id: str
|
||||
type: Literal["function_call"] = "function_call"
|
||||
status: ItemStatus
|
||||
call_id: str
|
||||
name: str
|
||||
arguments: str
|
||||
|
||||
|
||||
ResponseOutputItem = Annotated[
|
||||
ResponseOutputMessage | ResponseFunctionCall,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class ResponseError(_StrictModel):
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class ResponseUsage(_StrictModel):
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
total_tokens: int
|
||||
input_tokens_details: dict[str, Any] = Field(default_factory=dict)
|
||||
output_tokens_details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ResponseObject(_StrictModel):
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"id": "resp_xxx",
|
||||
"object": "response",
|
||||
"created_at": 1788739200,
|
||||
"completed_at": 1788739201,
|
||||
"status": "completed",
|
||||
"error": None,
|
||||
"incomplete_details": None,
|
||||
"instructions": None,
|
||||
"model": "assistant:asst_xxx",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_xxx",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "这是一个实时对话助手。",
|
||||
"annotations": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": False,
|
||||
"previous_response_id": None,
|
||||
"store": True,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"metadata": {},
|
||||
"usage": None,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
id: str
|
||||
object: Literal["response"] = "response"
|
||||
created_at: int
|
||||
completed_at: int | None = None
|
||||
status: ResponseStatus
|
||||
error: ResponseError | None = None
|
||||
incomplete_details: dict[str, Any] | None = None
|
||||
instructions: None = None
|
||||
model: str | None
|
||||
output: list[ResponseOutputItem]
|
||||
parallel_tool_calls: Literal[False] = False
|
||||
previous_response_id: str | None = None
|
||||
store: Literal[True] = True
|
||||
tool_choice: Literal["auto"] = "auto"
|
||||
tools: list[Any] = Field(default_factory=list)
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
usage: ResponseUsage | None = None
|
||||
|
||||
|
||||
class ResponseInputMessageItem(_StrictModel):
|
||||
id: str
|
||||
type: Literal["message"] = "message"
|
||||
role: Literal["user", "assistant"]
|
||||
content: list[MessageContent]
|
||||
status: ItemStatus = "completed"
|
||||
|
||||
|
||||
class ResponseFunctionCallOutputItem(_StrictModel):
|
||||
id: str
|
||||
type: Literal["function_call_output"] = "function_call_output"
|
||||
call_id: str
|
||||
output: str
|
||||
status: ItemStatus = "completed"
|
||||
|
||||
|
||||
ResponseStoredInputItem = Annotated[
|
||||
ResponseInputMessageItem | ResponseFunctionCallOutputItem,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
class ResponseInputItemList(_StrictModel):
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "msg_input_xxx",
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "你好"}
|
||||
],
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
"first_id": "msg_input_xxx",
|
||||
"last_id": "msg_input_xxx",
|
||||
"has_more": False,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
object: Literal["list"] = "list"
|
||||
data: list[ResponseStoredInputItem]
|
||||
first_id: str | None = None
|
||||
last_id: str | None = None
|
||||
has_more: bool
|
||||
|
||||
|
||||
class ModelObject(_StrictModel):
|
||||
id: str
|
||||
object: Literal["model"] = "model"
|
||||
created: int
|
||||
owned_by: str = "interactive-media"
|
||||
name: str
|
||||
capabilities: list[Literal["responses", "realtime"]]
|
||||
assistant_id: str
|
||||
assistant_type: str
|
||||
runtime_mode: str
|
||||
|
||||
|
||||
class ModelList(_StrictModel):
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "assistant:asst_xxx",
|
||||
"object": "model",
|
||||
"created": 1788739200,
|
||||
"owned_by": "interactive-media",
|
||||
"name": "产品顾问",
|
||||
"capabilities": ["responses", "realtime"],
|
||||
"assistant_id": "asst_xxx",
|
||||
"assistant_type": "prompt",
|
||||
"runtime_mode": "pipeline",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
object: Literal["list"] = "list"
|
||||
data: list[ModelObject]
|
||||
@@ -18,9 +18,11 @@ from services.openai_responses.protocol import (
|
||||
)
|
||||
from services.realtime.launcher import resolve_assistant_config
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
CONTINUABLE_STATUSES = {"completed"}
|
||||
IDEMPOTENCY_CONSTRAINT = "uq_openai_response_idempotency"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -33,6 +35,26 @@ class PreparedResponse:
|
||||
client_tool_results: list[dict[str, Any]]
|
||||
|
||||
|
||||
def _integrity_constraint_name(error: IntegrityError) -> str | None:
|
||||
current = getattr(error, "orig", None)
|
||||
seen: set[int] = set()
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
direct = getattr(current, "constraint_name", None)
|
||||
if direct:
|
||||
return str(direct)
|
||||
diagnostic = getattr(current, "diag", None)
|
||||
value = getattr(diagnostic, "constraint_name", None)
|
||||
if value:
|
||||
return str(value)
|
||||
current = getattr(current, "__cause__", None) or getattr(
|
||||
current,
|
||||
"__context__",
|
||||
None,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _content_text(item: dict[str, Any]) -> str:
|
||||
return "\n".join(
|
||||
str(part.get("text") or "")
|
||||
@@ -97,6 +119,7 @@ async def _load_chain(
|
||||
seen: set[str] = set()
|
||||
async with SessionLocal() as db:
|
||||
current_id: str | None = response_id
|
||||
is_requested_response = True
|
||||
while current_id:
|
||||
if current_id in seen or len(chain) >= 1000:
|
||||
raise ResponsesAPIError(
|
||||
@@ -104,12 +127,17 @@ async def _load_chain(
|
||||
)
|
||||
seen.add(current_id)
|
||||
row = await db.get(OpenAIResponse, current_id)
|
||||
if row is None or row.api_key_id != api_key_id:
|
||||
if (
|
||||
row is None
|
||||
or row.api_key_id != api_key_id
|
||||
or (is_requested_response and row.deleted_at is not None)
|
||||
):
|
||||
raise ResponsesAPIError(
|
||||
"Response not found", code="response_not_found", status_code=404
|
||||
)
|
||||
chain.append(row)
|
||||
current_id = row.previous_response_id
|
||||
is_requested_response = False
|
||||
chain.reverse()
|
||||
return chain
|
||||
|
||||
@@ -158,17 +186,23 @@ def _client_tool_results(
|
||||
chain: list[OpenAIResponse],
|
||||
current_items: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
calls = {
|
||||
str(item.get("call_id") or ""): item
|
||||
calls = [
|
||||
item
|
||||
for row in chain
|
||||
for item in row.output_items or []
|
||||
if item.get("type") == "function_call"
|
||||
]
|
||||
outputs = {
|
||||
str(item.get("call_id") or ""): item
|
||||
for item in current_items
|
||||
if item.get("type") == "function_call_output"
|
||||
}
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in current_items:
|
||||
if item.get("type") != "function_call_output":
|
||||
for call in calls:
|
||||
call_id = str(call.get("call_id") or "")
|
||||
item = outputs.get(call_id)
|
||||
if item is None:
|
||||
continue
|
||||
call = calls.get(str(item.get("call_id") or "")) or {}
|
||||
output = str(item.get("output") or "")
|
||||
try:
|
||||
value = json.loads(output)
|
||||
@@ -178,8 +212,9 @@ def _client_tool_results(
|
||||
value = {"status": "ok", "data": value}
|
||||
results.append(
|
||||
{
|
||||
"call_id": item.get("call_id"),
|
||||
"call_id": call_id,
|
||||
"name": call.get("name"),
|
||||
"arguments": call.get("arguments"),
|
||||
"result": value,
|
||||
}
|
||||
)
|
||||
@@ -190,6 +225,8 @@ async def prepare_response(
|
||||
request: ResponseRequest,
|
||||
*,
|
||||
api_key_id: str,
|
||||
idempotency_key: str | None = None,
|
||||
request_hash: str | None = None,
|
||||
) -> PreparedResponse:
|
||||
chain: list[OpenAIResponse] = []
|
||||
parent: OpenAIResponse | None = None
|
||||
@@ -221,7 +258,14 @@ async def prepare_response(
|
||||
)
|
||||
assistant_id = parent.assistant_id
|
||||
if request.model:
|
||||
requested_id = assistant_id_from_model(request.model)
|
||||
try:
|
||||
requested_id = assistant_id_from_model(request.model)
|
||||
except ValueError as exc:
|
||||
raise ResponsesAPIError(
|
||||
str(exc),
|
||||
code="invalid_model",
|
||||
param="model",
|
||||
) from exc
|
||||
if requested_id != assistant_id:
|
||||
raise ResponsesAPIError(
|
||||
"model must match the previous response",
|
||||
@@ -307,6 +351,8 @@ async def prepare_response(
|
||||
context_messages=[],
|
||||
runtime_state={},
|
||||
dynamic_variables=effective_variables,
|
||||
idempotency_key=idempotency_key,
|
||||
request_hash=request_hash,
|
||||
)
|
||||
async with SessionLocal() as db:
|
||||
if parent is None:
|
||||
@@ -334,7 +380,21 @@ async def prepare_response(
|
||||
conversation.status = "active"
|
||||
conversation.ended_at = None
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if (
|
||||
idempotency_key is not None
|
||||
and _integrity_constraint_name(exc) == IDEMPOTENCY_CONSTRAINT
|
||||
):
|
||||
raise ResponsesAPIError(
|
||||
"Idempotency-Key is already in use",
|
||||
code="idempotency_key_in_use",
|
||||
param="Idempotency-Key",
|
||||
status_code=409,
|
||||
) from exc
|
||||
raise
|
||||
await db.refresh(row)
|
||||
|
||||
return PreparedResponse(
|
||||
@@ -351,16 +411,99 @@ async def get_response(
|
||||
response_id: str,
|
||||
*,
|
||||
api_key_id: str,
|
||||
include_deleted: bool = False,
|
||||
) -> OpenAIResponse:
|
||||
async with SessionLocal() as db:
|
||||
row = await db.get(OpenAIResponse, response_id)
|
||||
if row is None or row.api_key_id != api_key_id:
|
||||
if (
|
||||
row is None
|
||||
or row.api_key_id != api_key_id
|
||||
or (row.deleted_at is not None and not include_deleted)
|
||||
):
|
||||
raise ResponsesAPIError(
|
||||
"Response not found", code="response_not_found", status_code=404
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def get_idempotent_response(
|
||||
*,
|
||||
api_key_id: str,
|
||||
idempotency_key: str | None,
|
||||
request_hash: str,
|
||||
) -> OpenAIResponse | None:
|
||||
"""Return a terminal replay or reject conflicting reuse of a request key."""
|
||||
|
||||
if idempotency_key is None:
|
||||
return None
|
||||
async with SessionLocal() as db:
|
||||
row = (
|
||||
await db.execute(
|
||||
select(OpenAIResponse).where(
|
||||
OpenAIResponse.api_key_id == api_key_id,
|
||||
OpenAIResponse.idempotency_key == idempotency_key,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if row.request_hash != request_hash:
|
||||
raise ResponsesAPIError(
|
||||
"Idempotency-Key was already used with a different request",
|
||||
code="idempotency_key_conflict",
|
||||
param="Idempotency-Key",
|
||||
status_code=409,
|
||||
)
|
||||
if row.deleted_at is not None:
|
||||
raise ResponsesAPIError(
|
||||
"The response created by this Idempotency-Key was deleted",
|
||||
code="idempotency_response_deleted",
|
||||
param="Idempotency-Key",
|
||||
status_code=409,
|
||||
)
|
||||
if row.status == "in_progress":
|
||||
raise ResponsesAPIError(
|
||||
"A request with this Idempotency-Key is still in progress",
|
||||
code="idempotency_key_in_use",
|
||||
param="Idempotency-Key",
|
||||
status_code=409,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def delete_response(
|
||||
response_id: str,
|
||||
*,
|
||||
api_key_id: str,
|
||||
) -> OpenAIResponse:
|
||||
"""Create a tombstone while retaining state needed by existing branches."""
|
||||
|
||||
async with SessionLocal() as db:
|
||||
row = await db.get(
|
||||
OpenAIResponse,
|
||||
response_id,
|
||||
with_for_update=True,
|
||||
)
|
||||
if (
|
||||
row is None
|
||||
or row.api_key_id != api_key_id
|
||||
or row.deleted_at is not None
|
||||
):
|
||||
raise ResponsesAPIError(
|
||||
"Response not found", code="response_not_found", status_code=404
|
||||
)
|
||||
if row.status == "in_progress":
|
||||
raise ResponsesAPIError(
|
||||
"An in-progress response cannot be deleted",
|
||||
code="response_not_ready",
|
||||
status_code=409,
|
||||
)
|
||||
row.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def finish_response(
|
||||
response_id: str,
|
||||
*,
|
||||
@@ -370,6 +513,7 @@ async def finish_response(
|
||||
dynamic_variables: dict[str, Any] | None = None,
|
||||
runtime_state: dict[str, Any] | None = None,
|
||||
error: dict[str, Any] | None = None,
|
||||
usage: dict[str, Any] | None = None,
|
||||
) -> OpenAIResponse:
|
||||
now = datetime.now(UTC)
|
||||
async with SessionLocal() as db:
|
||||
@@ -384,6 +528,7 @@ async def finish_response(
|
||||
if runtime_state is not None:
|
||||
row.runtime_state = runtime_state
|
||||
row.error = error
|
||||
row.usage = usage
|
||||
row.completed_at = now
|
||||
|
||||
conversation = (
|
||||
@@ -445,6 +590,8 @@ async def finish_response(
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def recover_interrupted_responses() -> None:
|
||||
"""Mark generations interrupted by a process restart as cancelled."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Wire-format helpers for the public Responses API subset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from db.models import OpenAIResponse
|
||||
|
||||
|
||||
def encode_sse(event: dict[str, Any]) -> bytes:
|
||||
"""Encode one named Responses event as an SSE record."""
|
||||
|
||||
return (
|
||||
f"event: {event['type']}\n"
|
||||
f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class SSEEventSequence:
|
||||
"""Attach the monotonically increasing sequence required by SDK models."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._next = 0
|
||||
|
||||
def encode(self, event_type: str, **payload: Any) -> bytes:
|
||||
event = {
|
||||
"type": event_type,
|
||||
"sequence_number": self._next,
|
||||
**payload,
|
||||
}
|
||||
self._next += 1
|
||||
return encode_sse(event)
|
||||
|
||||
|
||||
def assistant_text_item(item_id: str, text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": item_id,
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_output_items(
|
||||
*,
|
||||
text: str,
|
||||
text_item_id: str,
|
||||
function_calls: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
if text:
|
||||
items.append(assistant_text_item(text_item_id, text))
|
||||
items.extend(function_calls)
|
||||
return items
|
||||
|
||||
|
||||
def output_lifecycle_events(
|
||||
row: OpenAIResponse,
|
||||
*,
|
||||
started_text_items: set[str],
|
||||
replay_text: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build terminal item events shared by live and idempotent streams."""
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
for output_index, item in enumerate(row.output_items or []):
|
||||
if item.get("type") != "message":
|
||||
pending_item = {**item, "status": "in_progress"}
|
||||
if item.get("type") == "function_call":
|
||||
pending_item["arguments"] = ""
|
||||
events.append(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": pending_item,
|
||||
}
|
||||
)
|
||||
if item.get("type") == "function_call":
|
||||
item_id = str(item.get("id") or "")
|
||||
arguments = str(item.get("arguments") or "")
|
||||
if arguments:
|
||||
events.append(
|
||||
{
|
||||
"type": "response.function_call_arguments.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"delta": arguments,
|
||||
}
|
||||
)
|
||||
events.append(
|
||||
{
|
||||
"type": "response.function_call_arguments.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"name": str(item.get("name") or ""),
|
||||
"arguments": arguments,
|
||||
}
|
||||
)
|
||||
events.append(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
item_id = str(item["id"])
|
||||
content = item.get("content") or []
|
||||
part = content[0] if content else {
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": [],
|
||||
}
|
||||
text = str(part.get("text") or "")
|
||||
if item_id not in started_text_items:
|
||||
events.extend(
|
||||
[
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": output_index,
|
||||
"item": {**item, "status": "in_progress", "content": []},
|
||||
},
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": {
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
"annotations": [],
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
started_text_items.add(item_id)
|
||||
if replay_text and text:
|
||||
events.append(
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"delta": text,
|
||||
"logprobs": [],
|
||||
}
|
||||
)
|
||||
events.extend(
|
||||
[
|
||||
{
|
||||
"type": "response.output_text.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"text": text,
|
||||
"logprobs": [],
|
||||
},
|
||||
{
|
||||
"type": "response.content_part.done",
|
||||
"item_id": item_id,
|
||||
"output_index": output_index,
|
||||
"content_index": 0,
|
||||
"part": part,
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": output_index,
|
||||
"item": item,
|
||||
},
|
||||
]
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def stored_input_items(row: OpenAIResponse) -> list[dict[str, Any]]:
|
||||
"""Backfill stable wire IDs for records created before contract v1."""
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for index, stored in enumerate(row.input_items or []):
|
||||
item = dict(stored)
|
||||
item.setdefault("id", f"item_{row.id}_{index}")
|
||||
item.setdefault("status", "completed")
|
||||
result.append(item)
|
||||
return result
|
||||
Reference in New Issue
Block a user