feat: harden public OpenAI API contracts
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"""Shared HTTP primitives for the public OpenAI-compatible API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from starlette.datastructures import Headers, MutableHeaders
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
|
||||
PUBLIC_API_NAME = "OpenAI-compatible subset v1"
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
PUBLIC_API_BEARER = HTTPBearer(
|
||||
auto_error=False,
|
||||
scheme_name="PublicAPIKey",
|
||||
description="Long-lived sk-rt-... API key or an endpoint-approved client secret.",
|
||||
)
|
||||
REQUEST_ID_RESPONSE_HEADERS = {
|
||||
REQUEST_ID_HEADER: {
|
||||
"description": "Request identifier generated by the server or supplied by the caller.",
|
||||
"schema": {"type": "string"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class OpenAIErrorDetail(BaseModel):
|
||||
"""Error shape consumed by official OpenAI SDKs."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: str = "invalid_request_error"
|
||||
code: str
|
||||
message: str
|
||||
param: str | None = None
|
||||
|
||||
|
||||
class OpenAIErrorResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
error: OpenAIErrorDetail
|
||||
|
||||
|
||||
class DeletedResource(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str
|
||||
object: Literal["response"] = "response"
|
||||
deleted: Literal[True] = True
|
||||
|
||||
|
||||
PUBLIC_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
|
||||
400: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The request is unsupported or semantically invalid.",
|
||||
},
|
||||
401: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The API credential is missing, invalid, or expired.",
|
||||
},
|
||||
404: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The resource does not exist or belongs to another API key.",
|
||||
},
|
||||
409: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The requested state transition conflicts with current state.",
|
||||
},
|
||||
422: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The JSON body or query parameters do not match the schema.",
|
||||
},
|
||||
429: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "A request or concurrency limit was exceeded.",
|
||||
},
|
||||
503: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The service is temporarily unavailable.",
|
||||
},
|
||||
500: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The response failed because of an internal error.",
|
||||
},
|
||||
504: {
|
||||
"model": OpenAIErrorResponse,
|
||||
"description": "The response exceeded its generation timeout.",
|
||||
},
|
||||
}
|
||||
|
||||
_ERROR_EXAMPLES = {
|
||||
400: ("unsupported_parameter", "The requested parameter is not supported."),
|
||||
401: ("invalid_api_key", "Invalid or expired API key."),
|
||||
404: ("response_not_found", "Response not found."),
|
||||
409: ("response_not_ready", "The requested state transition conflicts."),
|
||||
422: ("invalid_request_error", "Request does not match the API schema."),
|
||||
429: ("rate_limit_exceeded", "The request limit was exceeded."),
|
||||
500: ("internal_error", "The response failed unexpectedly."),
|
||||
503: ("service_unavailable", "The service is temporarily unavailable."),
|
||||
504: ("response_timeout", "Response timed out."),
|
||||
}
|
||||
|
||||
|
||||
def error_payload(
|
||||
message: str,
|
||||
*,
|
||||
code: str,
|
||||
param: str | None = None,
|
||||
error_type: str = "invalid_request_error",
|
||||
) -> dict[str, Any]:
|
||||
return OpenAIErrorResponse(
|
||||
error=OpenAIErrorDetail(
|
||||
type=error_type,
|
||||
code=code,
|
||||
message=message,
|
||||
param=param,
|
||||
)
|
||||
).model_dump()
|
||||
|
||||
|
||||
for _status, _response in PUBLIC_ERROR_RESPONSES.items():
|
||||
_code, _message = _ERROR_EXAMPLES[int(_status)]
|
||||
_response["content"] = {
|
||||
"application/json": {
|
||||
"example": error_payload(
|
||||
_message,
|
||||
code=_code,
|
||||
)
|
||||
}
|
||||
}
|
||||
_response["headers"] = REQUEST_ID_RESPONSE_HEADERS
|
||||
|
||||
|
||||
def openai_error_response(
|
||||
message: str,
|
||||
*,
|
||||
code: str,
|
||||
status_code: int,
|
||||
param: str | None = None,
|
||||
error_type: str = "invalid_request_error",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=error_payload(
|
||||
message,
|
||||
code=code,
|
||||
param=param,
|
||||
error_type=error_type,
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def normalize_request_id(value: str | None) -> str:
|
||||
"""Reuse a safe caller ID or create one suitable for logs and support tickets."""
|
||||
|
||||
candidate = (value or "").strip()
|
||||
if _VALID_REQUEST_ID.fullmatch(candidate):
|
||||
return candidate
|
||||
return f"req_{uuid4().hex}"
|
||||
|
||||
|
||||
class RequestIDMiddleware:
|
||||
"""Add a stable request ID without buffering SSE response bodies."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
request_id = normalize_request_id(
|
||||
Headers(scope=scope).get(REQUEST_ID_HEADER)
|
||||
)
|
||||
scope.setdefault("state", {})["request_id"] = request_id
|
||||
|
||||
async def send_with_request_id(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
MutableHeaders(scope=message)[REQUEST_ID_HEADER] = request_id
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_request_id)
|
||||
Reference in New Issue
Block a user