18 KiB
API Contract
codex-proxy-rs exposes two API families. The /admin/* path is reserved for
the future human Web console; machine-readable management endpoints live under
/api/admin/*.
/v1/*
These endpoints are OpenAI-compatible and are authenticated only by enabled local client API keys created through /api/admin/api-keys and sent as Authorization: Bearer cpr_....
Responses include an X-Request-Id header for tracing, but the body stays OpenAI-compatible and does not include a custom requestId field.
Error body:
{
"error": {
"message": "Invalid client API key",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}
Streaming errors after SSE has started use:
event: error
data: {"error":{"message":"Upstream failed","type":"server_error","param":null,"code":"upstream_error"}}
Model Routes
GET /v1/models returns the OpenAI-compatible model list. The default static Codex model is gpt-5.5.
GET /v1/models/catalog returns Codex model metadata for UI use. Aliases are used for request parsing but are not exposed as standalone models.
GET /v1/models/{id} returns an OpenAI-compatible model object or a 404 OpenAI error body with code=model_not_found.
GET /v1/models/{id}/info returns the extended Codex catalog entry for a known model.
Model name parsing supports configured aliases plus -low, -medium, -high, -xhigh, -fast, and -flex suffixes.
GET /debug/fingerprint
Returns the local runtime Codex Desktop fingerprint summary. The route is local-only and rejects forwarded remote requests using x-forwarded-for or x-real-ip.
Success response:
{
"source": "staticDefault",
"originator": "Codex Desktop",
"appVersion": "26.519.81530",
"buildNumber": "3178",
"platform": "darwin",
"arch": "arm64",
"chromiumVersion": "146",
"userAgent": "Codex/26.519.81530 (darwin; arm64) Chromium/146"
}
GET /debug/upstream
Runs a local-only Codex backend connectivity probe against the Codex models endpoint using the runtime TLS and Desktop fingerprint headers. The route rejects forwarded remote requests using x-forwarded-for or x-real-ip, returns only status metadata, and does not expose account tokens or upstream response bodies.
Success response for a reachable backend that rejects the synthetic diagnostic token:
{
"target": "codexModels",
"status": "reachable",
"backendBaseUrl": "https://chatgpt.com/backend-api",
"endpoint": "https://chatgpt.com/backend-api/codex/models?client_version=26.519.81530",
"reachable": true,
"statusCode": 401,
"authorization": "rejected",
"durationMs": 23,
"error": null
}
GET /debug/diagnostics
Returns a local-only runtime diagnostics summary. The route is intended for production-local troubleshooting and rejects forwarded remote requests using x-forwarded-for or x-real-ip.
Success response:
{
"status": "ok",
"runtime": {
"packageName": "codex-proxy-rs",
"packageVersion": "0.1.0"
},
"paths": {
"config": "config.yaml",
"localConfig": "local.yaml",
"databaseUrl": "sqlite://data/codex-proxy.sqlite",
"logsDirectory": "logs",
"masterKeyFile": "data/master.key",
"apiKeyPepperFile": "data/api-key-pepper.key"
},
"transport": {
"backendBaseUrl": "https://chatgpt.com/backend-api",
"tls": {
"forceHttp11": false
},
"fingerprint": {
"source": "staticDefault",
"originator": "Codex Desktop",
"appVersion": "26.519.81530",
"buildNumber": "3178",
"platform": "darwin",
"arch": "arm64",
"chromiumVersion": "146",
"userAgent": "Codex/26.519.81530 (darwin; arm64) Chromium/146"
}
},
"accounts": {
"repositoryAvailable": true,
"authenticatedState": true,
"pool": {
"total": 1,
"active": 1,
"expired": 0,
"quotaExhausted": 0,
"refreshing": 0,
"disabled": 0,
"banned": 0
},
"capacity": {
"maxConcurrentPerAccount": 3,
"totalSlots": 3,
"usedSlots": 0,
"availableSlots": 3
}
},
"settings": {
"defaultModel": "gpt-5.5",
"refreshEnabled": true,
"rotationStrategy": "least_used",
"quotaSkipExhausted": true,
"logsEnabled": false
}
}
POST /v1/responses
Uses imported Codex accounts to call POST /codex/responses on the configured Codex backend. The upstream request is sent with Codex Desktop headers, account bearer token, optional account id, request id, and encrypted account-scoped Cookie replay.
When the client omits stream or sets "stream": false, the Rust route collects upstream HTTP SSE until response.completed and returns the completed OpenAI-compatible response JSON. If upstream sends assistant output through response.output_item.done or response.output_text.delta events while the completed payload has an empty output, the route reconstructs a standard assistant output message and synchronizes output_text.
When the client sets "stream": true, the Rust route returns text/event-stream and passes through upstream SSE frames while collecting usage for the account after the stream finishes. Both modes capture upstream Set-Cookie, replay encrypted account-scoped Cookies, record account usage when usage appears in SSE, and write a v1.response event log with requestId, accountId, route, model, statusCode, latencyMs, and non-secret metadata.
For non-streaming clients, upstream HTTP SSE event: error and event: response.failed terminal frames return a 502 OpenAI-compatible upstream_error JSON body instead of an empty-response error. For streaming clients, those terminal frames remain passthrough SSE output and the lifecycle log is marked as a failed upstream SSE response.
If Codex returns 401 and the account has a stored refresh token, the route refreshes the account through the OpenAI OAuth token endpoint once, persists the rotated access token, preserves the old refresh token when the server omits a new one, updates the in-process account pool, and retries the same request once.
Refresh failures are recorded as non-secret account.refresh event logs with the request id, account id, model, trigger, failure, and resulting accountStatus.
Responses use the Codex WebSocket upstream by default while keeping the client-facing response as SSE. previous_response_id always uses WebSocket so upstream conversation history is preserved. The local extension field use_websocket: false forces HTTP SSE for requests that do not require upstream history; the removed camelCase useWebSocket alias is ignored and no longer toggles transport.
/api/admin/*
Admin endpoints are authenticated only by HttpOnly admin session cookies.
Admin JSON uses lower camelCase field names. Every admin response includes an X-Request-Id header, and every JSON body includes requestId.
Use real HTTP status codes and body-level frontend codes together. Do not return HTTP 200 for failed requests. The body code exists for frontend branching; the HTTP status remains the transport truth.
GET /api/admin/diagnostics
Returns the same non-secret runtime diagnostics summary as /debug/diagnostics, wrapped in the admin envelope and gated by the admin session cookie.
Success response:
{
"code": 200,
"message": "OK",
"data": {
"status": "ok",
"runtime": {
"packageName": "codex-proxy-rs",
"packageVersion": "0.1.0"
},
"transport": {
"backendBaseUrl": "https://chatgpt.com/backend-api"
},
"accounts": {
"authenticatedState": true
}
},
"requestId": "req_01"
}
POST /api/admin/login
Authenticates only with the configured admin password stored in admin_users. Client API keys (Bearer cpr_...) are ignored by the admin login flow and cannot create admin sessions.
Request:
{
"password": "admin-password"
}
Success response:
{
"code": 200,
"message": "OK",
"data": {
"expiresAt": "2026-06-11T12:00:00Z"
},
"requestId": "req_01"
}
Success also returns:
Set-Cookie: cpr_admin_session=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600
Invalid password response uses HTTP 401 and body code 40102.
GET /api/admin/settings
Returns the in-scope runtime settings visible to the admin UI. This endpoint uses admin sessions, not client API keys.
Success response:
{
"code": 200,
"message": "OK",
"data": {
"defaultModel": "gpt-5.5",
"defaultReasoningEffort": null,
"serviceTier": null,
"modelAliases": {},
"refreshEnabled": true,
"refreshMarginSeconds": 300,
"refreshConcurrency": 2,
"maxConcurrentPerAccount": 3,
"requestIntervalMs": 50,
"rotationStrategy": "least_used",
"tierPriority": [],
"quotaRefreshIntervalMinutes": 5,
"quotaWarningThresholds": {
"primary": [80, 90],
"secondary": [80, 90]
},
"quotaSkipExhausted": true,
"logsEnabled": false,
"logsCapacity": 2000,
"logsCaptureBody": false,
"usageHistoryRetentionDays": null
},
"requestId": "req_01"
}
PATCH /api/admin/settings
Updates retained settings and writes them to root local.yaml as a config overlay. The route requires the admin session cookie. Client API keys and removed TypeScript-era fields are not accepted.
Request:
{
"defaultModel": "gpt-6",
"defaultReasoningEffort": "medium",
"serviceTier": "priority",
"modelAliases": {
"fast": "gpt-6-fast"
},
"refreshEnabled": false,
"refreshMarginSeconds": 180,
"refreshConcurrency": 3,
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 125,
"rotationStrategy": "round_robin",
"tierPriority": ["pro", "plus"],
"quotaRefreshIntervalMinutes": 15,
"quotaWarningThresholds": {
"primary": [75, 90],
"secondary": [65, 95]
},
"quotaSkipExhausted": false,
"logsEnabled": false,
"logsCapacity": 3000,
"logsCaptureBody": true,
"usageHistoryRetentionDays": 60
}
Success response uses the same data shape as GET /api/admin/settings.
Validation:
rotationStrategymust beleast_used,round_robin, orsticky.- Count/capacity/retention fields that represent concurrency, capacity, intervals, or retention must be greater than zero where zero would disable required service behavior.
- Quota warning thresholds must be between
0and100. - Unknown settings fields return HTTP
400with body code40001.
GET /api/admin/logs
Returns cursor-paginated event logs for admin troubleshooting. The list uses the standard admin page envelope and lower camelCase fields.
Supported query filters:
kindlevelrequestIdaccountIdroutemodelstatusCodesearchacross message and metadata text
Example item:
{
"id": "log_01",
"requestId": "req_01",
"kind": "v1.response",
"level": "info",
"accountId": "acct_01",
"route": "/v1/responses",
"model": "gpt-5.5",
"statusCode": 200,
"latencyMs": 123,
"message": "v1 responses 已完成",
"metadata": {
"stream": false,
"usage": {
"inputTokens": 7,
"outputTokens": 4,
"cachedTokens": 2,
"totalTokens": 11
}
},
"createdAt": "2026-06-11T12:00:00Z"
}
GET /api/admin/logs/state
Returns the current retained logging configuration plus the number of stored SQLite event logs.
Success response:
{
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"capacity": 2000,
"captureBody": false,
"storedCount": 42
},
"requestId": "req_01"
}
PATCH /api/admin/logs/state
Updates the in-process logging state used by admin log controls. This is runtime-only; persistent configuration changes still go through PATCH /api/admin/settings.
Request:
{
"enabled": true,
"capacity": 2000,
"captureBody": false
}
Success response uses the same data shape as GET /api/admin/logs/state.
GET /api/admin/logs/{id}
Returns a single stored event log by id using the same event shape as list items. Missing ids return HTTP 404 with body code 40401.
DELETE /api/admin/logs
Clears stored SQLite event logs.
Success response:
{
"code": 200,
"message": "OK",
"data": {
"cleared": 42
},
"requestId": "req_01"
}
GET /api/admin/usage-stats
Returns cursor-paginated account usage counters recorded after /v1/* calls. The endpoint never returns account access tokens, refresh tokens, Cookie values, or client API keys.
Example item:
{
"accountId": "acct_01",
"email": "user@example.com",
"label": "primary",
"planType": "plus",
"requestCount": 2,
"inputTokens": 14,
"outputTokens": 8,
"cachedTokens": 3,
"lastUsedAt": "2026-06-11T12:00:00Z"
}
GET /api/admin/usage-stats/summary
Returns global usage totals across accounts.
Success response:
{
"code": 200,
"message": "OK",
"data": {
"accountCount": 1,
"requestCount": 2,
"inputTokens": 14,
"outputTokens": 8,
"cachedTokens": 3
},
"requestId": "req_01"
}
GET /api/admin/api-keys
Returns local client API keys with cursor pagination. The plaintext key and hash are never returned by list endpoints.
POST /api/admin/api-keys
Creates a local client API key for /v1/*. The plaintext value is returned only in this response; store it client-side and use it as Authorization: Bearer <plaintext>.
Request:
{
"name": "cursor"
}
Success response:
{
"code": 200,
"message": "OK",
"data": {
"id": "key_01",
"name": "cursor",
"prefix": "cpr_xxxxxxxx",
"enabled": true,
"createdAt": "2026-06-11T12:00:00Z",
"lastUsedAt": null,
"plaintext": "cpr_full_key_returned_once"
},
"requestId": "req_01"
}
GET /api/admin/api-keys/export
Exports Rust-local client API key metadata for backup or migration. The export never includes plaintext keys, key hashes, or the raw auth pepper. Because cpr_ keys are stored only as peppered HMAC hashes, imports rotate keys instead of preserving the original plaintext.
Optional query:
GET /api/admin/api-keys/export?ids=key_01,key_02
Success response:
{
"code": 200,
"message": "OK",
"data": {
"sourceFormat": "rustLocalClientApiKeys",
"rotationRequired": true,
"apiKeys": [
{
"id": "key_01",
"name": "cursor",
"label": "automation",
"prefix": "cpr_xxxxxxxx",
"enabled": true,
"createdAt": "2026-06-11T12:00:00Z",
"lastUsedAt": null
}
]
},
"requestId": "req_01"
}
POST /api/admin/api-keys/import
Imports Rust-local client API key metadata exported by this service. Import creates new local cpr_ keys using the target instance's pepper and returns the new plaintext values only in this response. Clients must replace the old keys with these rotated plaintext values.
Request:
{
"sourceFormat": "rustLocalClientApiKeys",
"rotationRequired": true,
"apiKeys": [
{
"id": "key_01",
"name": "cursor",
"label": "automation",
"prefix": "cpr_xxxxxxxx",
"enabled": true
}
]
}
Success response:
{
"code": 200,
"message": "OK",
"data": {
"imported": 1,
"skipped": 0,
"rotated": true,
"keys": [
{
"sourceId": "key_01",
"sourcePrefix": "cpr_xxxxxxxx",
"id": "key_02",
"name": "cursor",
"label": "automation",
"prefix": "cpr_yyyyyyyy",
"enabled": true,
"createdAt": "2026-06-11T12:05:00Z",
"lastUsedAt": null,
"plaintext": "cpr_new_full_key_returned_once"
}
]
},
"requestId": "req_01"
}
GET /api/admin/accounts
Returns stored Codex accounts with cursor pagination. Access tokens and refresh tokens are never decrypted or returned.
GET /api/admin/accounts/quota-warnings
Returns active quota warnings computed from cached account quota snapshots in SQLite. The route requires an admin session cookie and does not call the upstream Codex backend.
Success response:
{
"code": 200,
"message": "OK",
"data": {
"warnings": [
{
"accountId": "acct_01",
"email": "user@example.com",
"window": "primary",
"level": "warning",
"usedPercent": 85.0,
"resetAt": 1770000100
}
],
"updatedAt": "2026-06-13T00:00:00Z"
},
"requestId": "req_01"
}
POST /api/admin/accounts/import
Imports accounts into encrypted SQLite storage. The request body uses the native Rust import format. The only supported shape is an object with accounts; unknown container or account fields are rejected by producing no importable accounts.
Request:
{
"accounts": [
{
"id": "acct_01",
"email": "user@example.com",
"accountId": "chatgpt-account",
"userId": "chatgpt-user",
"label": "primary",
"planType": "plus",
"token": "access-token",
"refreshToken": "refresh-token",
"status": "active"
}
]
}
Success response:
{
"code": 200,
"message": "OK",
"data": {
"imported": 1,
"skipped": 0,
"sourceFormat": "native"
},
"requestId": "req_01"
}
GET /api/admin/accounts/export
Exports native Rust account backups. The default format is native; format=native is the only explicit format accepted. Any other format returns HTTP 400.
Optional query:
GET /api/admin/accounts/export?ids=acct_01,acct_02&format=native
Success response:
{
"code": 200,
"message": "OK",
"data": {
"sourceFormat": "native",
"accounts": [
{
"id": "acct_01",
"email": "user@example.com",
"accountId": "chatgpt-account",
"userId": "chatgpt-user",
"label": "primary",
"planType": "plus",
"token": "access-token",
"refreshToken": "refresh-token",
"status": "active",
"accessTokenExpiresAt": null,
"addedAt": "2026-06-11T12:00:00Z",
"updatedAt": "2026-06-11T12:00:00Z"
}
]
},
"requestId": "req_01"
}
List body:
{
"code": 200,
"message": "OK",
"data": [],
"page": {
"limit": 50,
"nextCursor": null
},
"requestId": "req_01"
}
Error body:
{
"code": 40101,
"message": "Admin login required",
"data": null,
"requestId": "req_01"
}
Pagination uses cursor ordering by (created_at desc, id desc), default limit=50, max limit=200.
Rust structs use PascalCase type names and snake_case fields internally, then expose lower camelCase through serde:
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiEnvelope<T> {
pub code: u32,
pub message: String,
pub data: T,
pub request_id: String,
}