196 lines
5.6 KiB
Python
196 lines
5.6 KiB
Python
"""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)
|