"""Validation and wire helpers for the supported Responses API subset.""" 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"} 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]: return public_error_payload( error.message, code=error.code, param=error.param, ) 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") 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, Any]] = [] 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") item: dict[str, Any] = {"type": expected, "text": text} if expected == "output_text": item["annotations"] = [] result.append(item) return result 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: 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": 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( "Message role must be user or assistant", param=f"{param}.role" ) 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( "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( { "id": new_id("fc"), "type": "function_call_output", "call_id": call_id, "output": output, "status": "completed", } ) 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", ) 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") 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}" 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, "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": {}, "usage": getattr(row, "usage", None), }