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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user