feat: harden public OpenAI API contracts
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user