2026-09-07 00:15:32 +08:00
|
|
|
"""Validation and wire helpers for the supported Responses API subset."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-09-07 14:25:48 +08:00
|
|
|
import hashlib
|
2026-09-07 00:15:32 +08:00
|
|
|
import json
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from typing import Any
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
from db.models import OpenAIResponse
|
2026-09-07 14:25:48 +08:00
|
|
|
from services.openai_api import error_payload as public_error_payload
|
2026-09-07 00:15:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
LOCKED_FIELDS = {"instructions", "tools", "tool_choice"}
|
|
|
|
|
ALLOWED_FIELDS = {
|
|
|
|
|
"input",
|
|
|
|
|
"model",
|
|
|
|
|
"previous_response_id",
|
|
|
|
|
"store",
|
|
|
|
|
"stream",
|
|
|
|
|
"x_interactive_media",
|
|
|
|
|
}
|
|
|
|
|
UNSUPPORTED_FIELDS = {
|
|
|
|
|
"background",
|
|
|
|
|
"conversation",
|
|
|
|
|
"include",
|
|
|
|
|
"max_output_tokens",
|
|
|
|
|
"parallel_tool_calls",
|
|
|
|
|
"reasoning",
|
|
|
|
|
"temperature",
|
|
|
|
|
"text",
|
|
|
|
|
"top_p",
|
|
|
|
|
"truncation",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class ResponsesAPIError(ValueError):
|
|
|
|
|
message: str
|
|
|
|
|
code: str = "invalid_request_error"
|
|
|
|
|
param: str | None = None
|
|
|
|
|
status_code: int = 400
|
|
|
|
|
|
|
|
|
|
def __str__(self) -> str:
|
|
|
|
|
return self.message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ResponseRequest:
|
|
|
|
|
model: str | None
|
|
|
|
|
previous_response_id: str | None
|
|
|
|
|
input_items: list[dict[str, Any]]
|
|
|
|
|
stream: bool
|
|
|
|
|
dynamic_variables: dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def error_payload(error: ResponsesAPIError) -> dict[str, Any]:
|
2026-09-07 14:25:48 +08:00
|
|
|
return public_error_payload(
|
|
|
|
|
error.message,
|
|
|
|
|
code=error.code,
|
|
|
|
|
param=error.param,
|
|
|
|
|
)
|
2026-09-07 00:15:32 +08:00
|
|
|
|
|
|
|
|
|
2026-09-07 14:25:48 +08:00
|
|
|
def _text_content(value: object, *, role: str) -> list[dict[str, Any]]:
|
2026-09-07 00:15:32 +08:00
|
|
|
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")
|
2026-09-07 14:25:48 +08:00
|
|
|
item: dict[str, Any] = {"type": expected, "text": text}
|
|
|
|
|
if expected == "output_text":
|
|
|
|
|
item["annotations"] = []
|
|
|
|
|
return [item]
|
2026-09-07 00:15:32 +08:00
|
|
|
if not isinstance(value, list) or not value:
|
|
|
|
|
raise ResponsesAPIError("Message content must be text", param="input")
|
2026-09-07 14:25:48 +08:00
|
|
|
result: list[dict[str, Any]] = []
|
2026-09-07 00:15:32 +08:00
|
|
|
for part in value:
|
|
|
|
|
if not isinstance(part, dict) or part.get("type") != expected:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Only {expected} content is supported", param="input"
|
|
|
|
|
)
|
|
|
|
|
text = str(part.get("text") or "").strip()
|
|
|
|
|
if not text:
|
|
|
|
|
raise ResponsesAPIError("Message text cannot be empty", param="input")
|
2026-09-07 14:25:48 +08:00
|
|
|
item: dict[str, Any] = {"type": expected, "text": text}
|
|
|
|
|
if expected == "output_text":
|
|
|
|
|
item["annotations"] = []
|
|
|
|
|
result.append(item)
|
2026-09-07 00:15:32 +08:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_input(value: object) -> list[dict[str, Any]]:
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return [
|
|
|
|
|
{
|
2026-09-07 14:25:48 +08:00
|
|
|
"id": new_id("msg"),
|
2026-09-07 00:15:32 +08:00
|
|
|
"type": "message",
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": _text_content(value, role="user"),
|
2026-09-07 14:25:48 +08:00
|
|
|
"status": "completed",
|
2026-09-07 00:15:32 +08:00
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
if not isinstance(value, list) or not value:
|
|
|
|
|
raise ResponsesAPIError("input must be a non-empty string or array", param="input")
|
|
|
|
|
|
|
|
|
|
items: list[dict[str, Any]] = []
|
|
|
|
|
for index, raw in enumerate(value):
|
|
|
|
|
param = f"input[{index}]"
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
raise ResponsesAPIError("Input item must be an object", param=param)
|
|
|
|
|
item_type = str(raw.get("type") or "message")
|
|
|
|
|
if item_type == "message":
|
2026-09-07 14:25:48 +08:00
|
|
|
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]}",
|
|
|
|
|
)
|
2026-09-07 00:15:32 +08:00
|
|
|
role = str(raw.get("role") or "user")
|
|
|
|
|
if role not in {"user", "assistant"}:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"Message role must be user or assistant", param=f"{param}.role"
|
|
|
|
|
)
|
|
|
|
|
items.append(
|
|
|
|
|
{
|
2026-09-07 14:25:48 +08:00
|
|
|
"id": new_id("msg"),
|
2026-09-07 00:15:32 +08:00
|
|
|
"type": "message",
|
|
|
|
|
"role": role,
|
|
|
|
|
"content": _text_content(raw.get("content"), role=role),
|
2026-09-07 14:25:48 +08:00
|
|
|
"status": "completed",
|
2026-09-07 00:15:32 +08:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
if item_type == "function_call_output":
|
2026-09-07 14:25:48 +08:00
|
|
|
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]}",
|
|
|
|
|
)
|
2026-09-07 00:15:32 +08:00
|
|
|
call_id = str(raw.get("call_id") or "").strip()
|
|
|
|
|
if not call_id:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"function_call_output requires call_id",
|
|
|
|
|
param=f"{param}.call_id",
|
|
|
|
|
)
|
|
|
|
|
output = raw.get("output")
|
|
|
|
|
if not isinstance(output, str):
|
|
|
|
|
output = json.dumps(output, ensure_ascii=False, default=str)
|
|
|
|
|
items.append(
|
|
|
|
|
{
|
2026-09-07 14:25:48 +08:00
|
|
|
"id": new_id("fc"),
|
2026-09-07 00:15:32 +08:00
|
|
|
"type": "function_call_output",
|
|
|
|
|
"call_id": call_id,
|
|
|
|
|
"output": output,
|
2026-09-07 14:25:48 +08:00
|
|
|
"status": "completed",
|
2026-09-07 00:15:32 +08:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Unsupported input item type: {item_type}",
|
|
|
|
|
code="unsupported_parameter",
|
|
|
|
|
param=f"{param}.type",
|
|
|
|
|
)
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_request(body: object) -> ResponseRequest:
|
|
|
|
|
if not isinstance(body, dict):
|
|
|
|
|
raise ResponsesAPIError("Request body must be an object")
|
|
|
|
|
unknown = sorted(set(body) - ALLOWED_FIELDS - LOCKED_FIELDS - UNSUPPORTED_FIELDS)
|
|
|
|
|
if unknown:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Unsupported parameter: {unknown[0]}",
|
|
|
|
|
code="unsupported_parameter",
|
|
|
|
|
param=unknown[0],
|
|
|
|
|
)
|
|
|
|
|
locked = sorted(LOCKED_FIELDS.intersection(body))
|
|
|
|
|
if locked:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"{locked[0]} is owned by the selected assistant",
|
|
|
|
|
code="immutable_configuration",
|
|
|
|
|
param=locked[0],
|
|
|
|
|
)
|
|
|
|
|
unsupported = sorted(UNSUPPORTED_FIELDS.intersection(body))
|
|
|
|
|
if unsupported:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
f"Unsupported parameter: {unsupported[0]}",
|
|
|
|
|
code="unsupported_parameter",
|
|
|
|
|
param=unsupported[0],
|
|
|
|
|
)
|
|
|
|
|
if "store" in body and body["store"] is not True:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"store must be true for this stateful API",
|
|
|
|
|
code="unsupported_parameter",
|
|
|
|
|
param="store",
|
|
|
|
|
)
|
|
|
|
|
stream = body.get("stream", False)
|
|
|
|
|
if not isinstance(stream, bool):
|
|
|
|
|
raise ResponsesAPIError("stream must be a boolean", param="stream")
|
|
|
|
|
extension = body.get("x_interactive_media") or {}
|
|
|
|
|
if not isinstance(extension, dict):
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"x_interactive_media must be an object", param="x_interactive_media"
|
|
|
|
|
)
|
|
|
|
|
variables = extension.get("variables") or {}
|
|
|
|
|
if not isinstance(variables, dict):
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"variables must be an object",
|
|
|
|
|
param="x_interactive_media.variables",
|
|
|
|
|
)
|
2026-09-07 14:25:48 +08:00
|
|
|
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",
|
|
|
|
|
)
|
2026-09-07 00:15:32 +08:00
|
|
|
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")
|
|
|
|
|
raw_previous = body.get("previous_response_id")
|
|
|
|
|
if raw_previous is not None and not isinstance(raw_previous, str):
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"previous_response_id must be a string", param="previous_response_id"
|
|
|
|
|
)
|
|
|
|
|
model = (raw_model or "").strip() or None
|
|
|
|
|
previous = (raw_previous or "").strip() or None
|
|
|
|
|
if not model and not previous:
|
|
|
|
|
raise ResponsesAPIError(
|
|
|
|
|
"model is required for the first response", param="model"
|
|
|
|
|
)
|
|
|
|
|
if "input" not in body:
|
|
|
|
|
raise ResponsesAPIError("input is required", param="input")
|
|
|
|
|
return ResponseRequest(
|
|
|
|
|
model=model,
|
|
|
|
|
previous_response_id=previous,
|
|
|
|
|
input_items=normalize_input(body["input"]),
|
|
|
|
|
stream=stream,
|
|
|
|
|
dynamic_variables=dict(variables),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def new_id(prefix: str) -> str:
|
|
|
|
|
return f"{prefix}_{uuid4().hex}"
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 14:25:48 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 00:15:32 +08:00
|
|
|
def response_payload(row: OpenAIResponse) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": row.id,
|
|
|
|
|
"object": "response",
|
|
|
|
|
"created_at": int(row.created_at.timestamp()),
|
|
|
|
|
"completed_at": (
|
|
|
|
|
int(row.completed_at.timestamp()) if row.completed_at else None
|
|
|
|
|
),
|
|
|
|
|
"status": row.status,
|
|
|
|
|
"error": row.error,
|
|
|
|
|
"incomplete_details": None,
|
|
|
|
|
"instructions": None,
|
|
|
|
|
"model": f"assistant:{row.assistant_id}" if row.assistant_id else None,
|
|
|
|
|
"output": list(row.output_items or []),
|
|
|
|
|
"parallel_tool_calls": False,
|
|
|
|
|
"previous_response_id": row.previous_response_id,
|
|
|
|
|
"store": True,
|
|
|
|
|
"tool_choice": "auto",
|
|
|
|
|
"tools": [],
|
|
|
|
|
"metadata": {},
|
2026-09-07 14:25:48 +08:00
|
|
|
"usage": getattr(row, "usage", None),
|
2026-09-07 00:15:32 +08:00
|
|
|
}
|