Skip to content

Latest commit

 

History

History
211 lines (177 loc) · 60.9 KB

api.en.md

File metadata and controls

211 lines (177 loc) · 60.9 KB

English version of api.md. If the two differ, the Chinese file is authoritative.

YZ AI Gateway — Admin API Contract

All admin endpoints live under /api, with JSON requests / responses. Authentication uses Authorization: Bearer <jwt>.

Uniform error format: {"error": "message", "code": "snake_case_code"}, HTTP status 4xx/5xx.

List endpoints are uniform: query parameters page (starting at 1) and page_size (default 20, max 200); response {"items": [...], "total": N}. Time-range parameter range: 24h | 7d | 30d | custom (together with from, to, RFC3339).

Data-plane rate limiting: user groups and API keys each have per-minute request-count / token-count caps (0 = unlimited), measured over the last 60 seconds; requests are counted at admission, tokens at completion; exceeding a cap returns 429 rate_limited / token_rate_limited with Retry-After. An expired key returns 401 api_key_expired; a model outside the key's allow-list returns 403 key_model_not_allowed.

Data-plane endpoints (called by clients, API-key authentication): GET /v1/models, GET /v1/models/{id}, POST /v1/chat/completions, POST /v1/responses, POST /v1/messages, POST /v1/messages/count_tokens, POST /v1/embeddings, POST /v1/images/generations, POST /v1/images/edits, POST /v1/images/variations, POST /v1/<custom path> (paths declared by custom JSON accounts, see below); Google Gemini native entry points GET /v1beta/models, GET /v1beta/models/{model}, POST /v1beta/models/{model}:generateContent, POST /v1beta/models/{model}:streamGenerateContent?alt=sse, POST /v1beta/models/{model}:countTokens (the /v1-prefixed forms such as /v1/models/{model}:generateContent are accepted as well). For compatibility with a variety of programming clients:

  • Authentication headers: all four of Authorization: Bearer, x-api-key, api-key and x-goog-api-key are accepted, as is the Gemini REST-style ?key= query parameter; paths with /v1 omitted or repeated are also accepted; /v1/* supports CORS preflight, so in-browser clients can connect directly.
  • Model-name resolution: the model name sent by the client is matched against the mappings in order: exact, case-insensitive, with the vendor prefix stripped (anthropic/, openai/, models/ etc.), with -latest stripped, and with version / date suffix tolerance (claude-sonnet-4-5-20250929 hits the mapping claude-sonnet-4-5, and vice versa; only numeric or date-shaped suffixes are accepted, gpt-5-codex does not hit gpt-5); logs and reports record the resolved name. Ambiguity (multiple candidates of the same length, or multiple mappings that differ only in case) returns 400 model_ambiguous; the upstream is not called and the request does not fall through to passthrough.
  • Passthrough of unmapped models: once an account has passthrough_models enabled, model names with no mapping are forwarded to that account as-is (restricted to text / embedding / image generation according to the account type). Explicit mappings take precedence; passthrough is not allowed when the user group is bound to a model group.
  • Images: generation, editing and variations on one account: an image account and its model mapping serve POST /v1/images/generations (text to image), POST /v1/images/edits (image to image / inpainting / multi-image composition) and POST /v1/images/variations alike; no second account or mapping is needed for image-to-image. edits / variations accept multipart/form-data (OpenAI shape: one or more image / image[] files, optional mask, prompt, model, n, size, quality, ...) and JSON (for upstreams that take images as URLs / base64). A multipart form is parsed and re-encoded verbatim toward <base URL>/images/edits etc.: every part keeps its field name, filename, Content-Type and bytes, only model is replaced by the mapped upstream name (a missing model is 400 missing_model, a broken form 400 invalid_body); the reply is returned unchanged. The whole body is bounded by Settings → Performance → max request body (20 MB by default; raise it for multi-image edits; 413 above the limit). Usage: input_tokens / output_tokens (gpt-image family, cached detail from input_tokens_details.cached_tokens) or prompt_tokens / completion_tokens (cached detail from prompt_tokens_details.cached_tokens) from the reply's usage are logged and priced; the two are aliases (OpenAI names win, never summed) and cached input is priced at the cached rate; image models that report no usage (per-image billing) stay unknown / unpriced.
  • Custom JSON endpoints (non-chat models): an account of type custom declares the client paths it serves (endpoints, relative to /v1, e.g. /systemone). Clients POST /v1/<path> with a JSON body that must carry model; after authentication, model resolution (mapping or passthrough; the model's type must be custom), group authorisation, rate limits, concurrency and account health, the gateway forwards the body verbatim to <account base URL>/<path> (only model is rewritten to the mapped upstream name; the account key is injected as Authorization: Bearer, plus x-api-key when the provider's auth_header says so) and returns the upstream JSON reply unchanged (body and 2xx status code as sent by the upstream, the call log records the same code; with the X-Upstream-* headers). No protocol conversion, no streaming. Only accounts that declare the path are candidates; when a model is mapped on several accounts, priority / weight ordering applies among those declaring the path. Usage is read from the reply's usage as prompt_tokens / completion_tokens or input_tokens / output_tokens: the two pairs are aliases and are never added; the OpenAI pair wins when any of its fields is present, otherwise the other pair is used; presence is decided per field, so an explicit 0 is a confirmed zero (usage status confirmed, on success and error replies alike and for every attempt of a retry chain) and only a usage block with none of the four fields is unknown (an error reply without usage still follows the status-code rule); total_tokens is informational, the parts are authoritative. Cost is priced by model name from the price table. Path rules: leading /, no trailing /, no query string / fragment / whitespace / .., at most 128 bytes, at most 20 paths per account, and never under a built-in route (/chat, /responses, /messages, /embeddings, /images, /models); the admin API normalises paths on save: adds the leading /, strips every leading /v1 segment (/v1/x and /v1/v1/x are both stored as /x, so an endpoint's own first segment can never be v1; /v1beta/x and /v10/x are different segments and are kept), strips the trailing /, deduplicates. Normalisation is idempotent: saving a list read back from the API never changes it, and an input that resolves to a built-in route after stripping is refused on the first save. Dispatch re-checks the reserved prefixes, so a /chat/... path written directly into the database is never served. A /v1/<path> no enabled account declares answers 404 not_found after key authentication (401 without a valid key, so the existence of a path is not disclosed); a custom model on the chat / embeddings / images entry points, or any other type on a custom path, answers 400 model_type_mismatch. Built-in presets: typesafe (TypeSafe Jev, base URL https://api.typesafe.ai/v1, default path /systemone) and custom-json (any JSON API). Saving or "testing" a custom account performs no live call.
  • POST /v1/messages/count_tokens: goes through the same front half as a generation request (authentication, body limit and memory budget, model resolution, user-group and model-group authorization, global / group / key concurrency slots) and is therefore constrained by the same performance settings; when an Anthropic-protocol account can serve the model the request is forwarded as-is (model name rewritten, anthropic-beta passed through); otherwise the count is estimated from the request body size and returned with X-Token-Count-Estimated: true. The estimate is only a context aid, not a metering value, and does not enter the usage ledger.
  • GET /v1/models / GET /v1/models/{id}: each item carries both the OpenAI fields (object:"model"/created/owned_by) and the Anthropic ModelInfo fields (type:"model"/display_name/created_at); the list includes has_more; the gateway's own classification is in model_type (text / embedding / image), and kind is model / virtual / group. Unmapped names that a passthrough account can serve are likewise returned as available by the detail query.
  • When the final failure is a 429, the upstream Retry-After is passed through to the client.
  • Client identification: every log row records client (a short label identified from User-Agent and x-app / originator / X-Title / x-client-name / Referer / HTTP-Referer; matched on the whole product name rather than as a substring, and only the host name of Referer is considered: claude-code, codex, gemini-cli, opencode, cline, roo-code, cursor, kimi-code, zcode, hermes, deepseek-harness, anthropic-sdk, openai-sdk, genai-sdk, curl, python-http, node-http, browser; anything else takes the first product name of the User-Agent, and empty yields unknown) and user_agent truncated to 200 characters. Admin logs can be filtered by client, and GET /api/admin/logs/filters returns the clients[] that have appeared; usage reports (admin and user center) include by_client, which is computed from the detail logs (so it only covers the log retention period), not from the hourly rollups, but it uses the same hour window as the rollups, [from truncated to the hour, end of the hour containing to), so the sum of its rows matches the top-level summary.
  • Gemini native protocol (gemini-generate): the model name is taken from the URL; streaming is determined by :streamGenerateContent (always answered as SSE, with usageMetadata delivered in the last event); errors are returned in Google's {"error":{"code","message","status"}} shape (UNAUTHENTICATED / PERMISSION_DENIED / NOT_FOUND / RESOURCE_EXHAUSTED …); countTokens goes through the same authentication / model resolution / authorization checks as a generation request and is then estimated locally, without calling the upstream. When the same protocol is connected directly to a native Gemini account, the request body is forwarded as-is (Gemini rejects unknown fields, so model is not injected) and only x-goog-api-key is sent; across protocols, Gemini ↔ Chat are converted in both directions: systemInstruction ↔ system, functionCall / functionResponse ↔ tool_calls / tool (paired by function name in order with synthesized call IDs; consecutive tool results are merged into one user turn), inlineData ↔ data URL images, thought parts ↔ reasoning, thinkingBudget ↔ reasoning_effort, responseMimeType/responseSchema ↔ response_format, and thoughtsTokenCount is counted into output tokens.

Capability boundaries of cross-protocol conversion (only happens when the client protocol differs from the upstream protocol; a same-protocol direct connection only rewrites the model name and passes every other field through as-is):

Content Chat ↔ Messages Responses ↔ Chat Notes
Text, system prompt, multi-turn messages Kept Kept
Tool definitions, tool calls, tool results, call IDs Kept Kept Streamed argument fragments are reassembled
Images (base64 / URL) Kept Kept
thinking / reasoning content Text kept Summary kept Vendor signatures (signature) cannot be mapped across protocols
cache_control prompt-cache markers Lost Lost Only effective on a same-protocol direct connection to Anthropic
Responses stateful field previous_response_id Not applicable Rejected, 400 Requires a native Responses upstream
Extension fields such as metadata, service_tier, prompt_cache_key Lost Lost Kept on a same-protocol direct connection
usage metering Kept Kept

Recommendation: enable on each provider account the protocol it natively supports, so that clients hit the same-protocol path; conversion is only a fallback.

Public

Method Path Description
GET /health/live {"status":"ok"}
GET /health/ready {"status":"ok"} or 503 {"status":"not_ready"}
GET /api/public/info {site_name, version, base_url}
GET /metrics Prometheus text-format metrics; once YZAPI_METRICS_TOKEN is set, Authorization: Bearer <token> is required

Authentication

Method Path Request Response
POST /api/auth/login {username, password} {token, user}; a locked user returns 423; wrong password 401
POST /api/auth/logout {}
GET /api/auth/me user
POST /api/auth/change-password {old_password, new_password} {}; after success the old token is invalidated and the user must log in again

user object: {id, username, role: "admin"|"user", group_id, group_name, enabled, locked, must_change_password, note, last_login_at, created_at}

Admin /api/admin/* (admin only)

Overview

  • GET /api/admin/overview/live → {active_users, streams, inflight, limit, waiting, queue_size, accounts:[{id,name,provider,priority,current,limit,util,health,cooldown_until,last_error}], groups:[{id,name,current,limit,util,tokens_used,token_quota}]}
  • GET /api/admin/overview/usage?range=24h → {tokens:{total,prompt,completion,cached,cache_rate}, requests:{total,success,failed,fail_rate}, active_users, active_keys, trend:[{time, total_tokens, prompt_tokens, completion_tokens, cached_tokens, requests}]}

Providers and models

  • GET /api/admin/providers → [{key,name,base_url,types[],protocols[],account_types:[{key,name,base_url,protocols[]}],auth_header,discover,custom,icon,endpoints[]?,default_mappings:[{request_model,upstream_model}]?}]. When account_types[].protocols is present it means that entry point only speaks those protocols (for example the Anthropic-compatible entry points of DeepSeek / Kimi / Zhipu / MiniMax / Bailian only speak anthropic-messages; the Gemini preset's "native Gemini API" entry point only speaks gemini-generate, while its "OpenAI-compatible" entry point speaks openai-completions / openai-embeddings); when an account is created, both the protocol default and the validation are based on it, to avoid mixing different protocols under the same Base URL. POST /accounts/discover may carry account_type / protocols; the native Gemini entry point fetches models[].name with x-goog-api-key and strips the models/ prefix endpoints and default_mappings appear only on presets without a model-listing API (e.g. typesafe: /systemone, jev → jev-latest): the admin form fills them in when the provider is chosen, and the create API applies default_mappings when a request carries no mappings and passthrough is off, and the preset endpoints when none are given.
  • GET /api/admin/models → [{name,type,kind:"model"|"virtual"|"group",provider,accounts,models[]}] (every request model currently routable)

Account pool /api/admin/accounts

Account object:

{id, name, provider, account_type, type:"text"|"image"|"embedding" | "custom", endpoints: [], base_url, has_key:true, api_key_masked:"sk-ab…yz",
 protocols:["openai-completions",...], mappings:[{id,request_model,upstream_model}], test_model, priority, weight, max_concurrency,
 enabled, health:"available"|"cooling"|"unavailable", cooldown_until, last_error, note, created_at, updated_at}
  • GET /api/admin/accounts?provider=&type=&protocol=&enabled=&health=&q=&page=&page_size=
  • POST /api/admin/accounts body: {name, provider, account_type, type, base_url, api_key, protocols[], mappings:[{request_model,upstream_model}], test_model, priority, max_concurrency, enabled, note, skip_test:false} → account object. Before saving, a minimal call is made for validation (text / embedding); on failure it returns 400 {error, code:"validation_failed"}.
  • GET /api/admin/accounts/:id
  • PUT /api/admin/accounts/:id same as POST; an empty api_key means keep the existing one. type cannot be changed.
  • Scheduling order: accounts are first tiered by priority in ascending order; within one tier they are ordered by weighted random on weight (1–1000, default 1), so weights 5 and 95 side by side give a 5% canary. When an account's cooldown expires it enters a half-open state: only one probe request is let through; success restores the account, failure extends the cooldown with backoff, and the remaining requests still treat the account as unavailable during the probe.
  • Successful data-plane responses carry the X-Upstream-Account / X-Upstream-Model / X-Upstream-Protocol headers, naming the account, upstream model and protocol that actually served the request.
  • DELETE /api/admin/accounts/:id; returns 409 when referenced by the vector service.
  • PATCH /api/admin/accounts/:id/enabled {enabled}
  • POST /api/admin/accounts/:id/reset-health → clears the cooldown
  • POST /api/admin/accounts/:id/test-model {model} → {ok, latency_ms, message, model}, probes the given upstream model with the stored credentials
  • POST /api/admin/accounts/:id/cache-check {model} → {ok, hit, protocol, model, first:{prompt_tokens,cached_tokens,cache_write_tokens,latency_ms}, second:{...}, message}: sends two consecutive requests of about 1.5k tokens with an identical prefix (with cache_control on the Anthropic protocol); non-zero cache fields on the second one mean a hit; used to prove that prompt caching really takes effect on that account and model
  • PUT /api/admin/accounts/:id/mappings {mappings:[{request_model,upstream_model}]} → account object; replaces the mappings as a whole (1–100 entries, request model names unique)
  • POST /api/admin/accounts/discover {provider, base_url, api_key, account_id} → {models:["..."]} (when account_id is given and api_key is empty, the stored key is used)
  • POST /api/admin/accounts/test same body as POST account (may carry account_id) → {ok, latency_ms, message, model}

Model groups /api/admin/model-groups

Object: {id, name, type, models:["a","b"], note, created_at, updated_at, in_use_by_route:bool, route_roles:["simple"|"complex"]}

  • GET (supports type=, q=), POST, GET /:id, PUT /:id, DELETE /:id (returns 409 when referenced by smart routing)

Users /api/admin/users

  • GET ?role=&group_id=&enabled=&q=&page=; each item is the user object + api_keys_count + is_last_admin (computed server-side over all enabled admins; the frontend uses it to disable the disable / demote / delete actions on the last admin)
  • POST {username, password, group_id, role:"user"|"admin", note}
  • PUT /:id {group_id, role, note}
  • DELETE /:id
  • PATCH /:id/enabled {enabled}
  • POST /:id/reset-password {password}
  • POST /:id/unlock Last-admin protection: disabling / demoting / deleting the last admin returns 409 code:"last_admin".

User groups /api/admin/user-groups

Object: {id, name, max_concurrency, key_max_concurrency, token_quota, is_default, enabled, note, model_group_ids:[...], model_groups:[{id,name}], members_count, tokens_used_month, created_at}

  • GET, POST {name,max_concurrency,key_max_concurrency,token_quota,tokens_per_minute,requests_per_minute,model_group_ids[],enabled,note}, PUT /:id, DELETE /:id (has members: 409 has_members; default group: 409 is_default), PATCH /:id/enabled

Smart routing /api/admin/route

  • Samples: GET /samples?label=&q=&vectorized=true|false&page=; object {id,label:"simple"|"complex",text,threshold,note,vector_dim,vectorized:bool,created_at}
  • POST /samples {label,text,threshold,note,build_vector:true}; PUT /samples/:id (when the text changes and build_vector is true, the vector is rebuilt synchronously; on failure the response carries build_error); DELETE /samples/:id
  • POST /samples/batch {items:[{label,text,note}], build_vector:true} → {created:N}
  • POST /samples/build {ids:[1,2]} or {all:true} → {built:N, failed:N, error}
  • POST /preview {text} → {label, source, confidence, group_id, group_name, models:[...], top_k:[{id,label,text,score}], normalized, latency_ms}
  • Decision logs: GET /decisions?label=&source=&request_type=&model=&q=&range=&page=; object {id,request_id,label,source,confidence,selected_model,model_group,normalized_text,top_k:[...],request_type,total_tokens,latency_ms,failed,created_at}; GET /decisions/:id
  • GET /stats?range=24h → {decisions, requests, failed, total_tokens, avg_tokens, latency_ms, by_label:[{key,count,tokens}], by_source:[...], by_model:[...], by_token_bucket:[{key,count}]}

Content compliance /api/admin/compliance

  • Policy groups /policy-groups: object {id,name,action:"block"|"audit",risk_level:"low"|"medium"|"high",enabled,description,words_count,samples_count,created_at}; GET, POST, PUT /:id, DELETE /:id (409 when referenced), PATCH /:id/enabled
  • Sensitive words /words: object {id,policy_group_id,policy_group:{id,name,action,risk_level},word,note,enabled,created_at}; GET ?policy_group_id=&enabled=&q=&page=, POST, PUT /:id, DELETE /:id, PATCH /:id/enabled, POST /words/batch {policy_group_id, words:["a","b"]} → {created}
  • Audit samples /samples: object {id,policy_group_id,policy_group,text,note,enabled,vector_dim,vectorized,created_at}; GET, POST {policy_group_id,text,note,enabled,build_vector}, PUT /:id, DELETE /:id, POST /samples/build {ids|all}
  • Audit logs /audit-logs: GET ?action=&risk_level=&detect_method=&policy_group_id=&q=&range=&page=; object {id,request_id,user_id,username,request_model,protocol,action,risk_level,detect_method,policy_group_id,policy_group,evidence,confidence,status_code,hits:[{method,policy_group,evidence,score,action,risk_level}],snippet,created_at}; GET /audit-logs/:id
  • POST /test {text} → verdict (same structure as hits + hit, block)

Call logs /api/admin/logs

  • GET ?user_id=&group_id=&account_id=&provider=&api_type=&result=&status_code=&model=&q=&range=&from=&to=&page=
  • Object: {id,request_id,user_id,username,group_id,group_name,api_key_id,api_key_name,account_id,account_name,provider,request_model,upstream_model,model_group,api_type,client_protocol,upstream_protocol,stream,prompt_tokens,completion_tokens,total_tokens,cached_tokens,tokens_known,result:"success"|"client_error"|"upstream_error"|"blocked"|"rate_limited",status_code,latency_ms,upstream_latency_ms,first_byte_ms,first_content_ms,queue_wait_ms,client_write_ms,client,user_agent,error,attempts:[{account_id,account_name,provider,protocol,model,status_code,latency_ms,error}],route_label,client_ip,created_at}
  • GET /:id
  • GET /api/admin/logs/filters (also returns clients[]: the client labels that have appeared in the logs) → {users:[{id,username}], accounts:[{id,name,provider}], providers:[...], models:[...]}
  • Semantics of the latency fields (milliseconds). Time points (measured from the moment the request entered the gateway): first_byte_ms is when the response headers of the successful upstream attempt were received; it includes queueing and earlier failed attempts and is not the time the client saw the first text; first_content_ms is the moment the first event carrying generated content (a non-empty body text, thinking text, a named tool-call start event, or a tool-argument delta) was accepted by the connection, i.e. after the Write that completed that event returned successfully; role events, empty deltas, signatures, heartbeats and usage-only events do not count; for non-streaming it is after the body Write succeeded. Durations: latency_ms is the whole request; queue_wait_ms is the length of the period spent waiting for gateway / user-group / key concurrency slots (excluding account concurrency slots), where 0 may mean no queueing or less than 1 ms; upstream_latency_ms runs from sending to the upstream until forwarding ends (including the interleaved client writes when streaming); client_write_ms is the accumulated time, when streaming, that writes and flushes to the client were blocked — a large value indicates a slow write path (downstream receiving, network or local writes), but a reverse proxy can first receive everything quickly and buffer it itself, so a small value does not rule out reverse-proxy buffering. first_content_ms − first_byte_ms is the interval "from upstream response headers to the gateway's first content write"; it includes upstream waiting, event reading, protocol conversion, scheduling and writing, and for non-streaming also reading the whole response, so it cannot be attributed to the upstream alone; first_byte_ms − queue_wait_ms approximates the gateway's pre-processing plus the upstream's first packet. Only samples that succeeded and actually wrote content satisfy queue_wait_ms ≤ first_byte_ms ≤ first_content_ms ≤ latency_ms; first_byte_ms of failed requests, first_content_ms of streams that had no content event, and historical rows from before the upgrade are all 0 and must be excluded before comparing. Between these numbers and the total time measured on the client side there are still the client connection and the network transfer, so they cannot simply be subtracted from each other to assign blame.
  • The log object also contains usage_status (confirmed: the upstream returned complete usage; partial: the stream was interrupted and only partial usage was obtained; unknown: the upstream processed the request but returned no usage; none: the request was not processed by any upstream) and est_prompt_tokens (when not confirmed, a rough estimate of request body bytes / 4, for reference only, not a lower bound; the deviation is large when images or metadata are included). Each entry of attempts[] carries usage_status, prompt_tokens, completion_tokens. usage_corrected being true means the request was written by an older gateway version that recorded only the last attempt's usage, and it has since been re-aggregated from the per-attempt records during the upgrade or a rebuild (with the same folding rule the current gateway applies to live requests); the original attempt records are untouched.

Usage statistics /api/admin/usage

  • GET ?user_id=&group_id=&account_id=&provider=&api_type=&model=&api_key_id=&range=&from=&to=&group_by=model|api_key
  • Response:
{summary:{requests,success,failed,prompt_tokens,completion_tokens,total_tokens,cached_tokens},
 trend:[{time, requests, total_tokens, series:{"<model or key name>": tokens}}],
 by_provider:[{key,name,requests,total_tokens,cached_tokens}], by_model:[...], by_model_group:[...],
 by_account:[...], by_group:[...], by_user:[...], by_api_key:[...]}

key is the id or name, name is the display name; deleted resources have (已删除) ("deleted") appended to name.

Metering maintenance /api/admin/usage

  • The usage response's summary and every breakdown item contain unknown_usage (the number of requests whose usage is partial / unknown); every breakdown item additionally contains attempts. Attribution rules: requests / success and failure / latency are recorded against the account that produced the final answer, while tokens are recorded against the account and provider of the upstream attempt that actually consumed them; a single request retried across accounts therefore splits its tokens across several rows, but the request is counted only once; the token totals of users, user groups and API keys always equal the request totals. The hourly table usage_hourlies likewise has an attempts column; regular ingestion and rebuild use the same aggregation function. Upgrade compatibility: on the first start with the new version, NULL attempts are backfilled once and the hours within the retention period whose detail rows are still complete are rebuilt under the new semantics (recorded in metering.attempts_since); hours earlier than that time never recorded attempt counts, so an attempts of 0 there means "not recorded" rather than a true zero attempts. The accumulation statements tolerate NULL. Requests undercounted by older versions (the request level only had the last attempt) are corrected from the attempt records during a rebuild or during journal replay ingestion and flagged usage_corrected; processing them again does not count them twice. The portion of the request-level total_tokens that exceeds the sum of input + output over all attempts (tokens the provider reports outside input and output, preserved during correction) is recorded in total_tokens of the final-answer account's row and is not disguised as input or output; the aggregated total_tokens therefore always equals the request's total_tokens, and reconciliation uses that as the reference. When the aggregation semantics change, the upgrade marker carries a version number (currently 3); a database marked with an older version rebuilds the ranges whose detail rows are still complete once more at startup; already-purged ranges are not touched.
  • POST /api/admin/usage/rebuild {from, to} (RFC3339, at most 92 days; from must not be earlier than the call-log retention period, nor earlier than the persisted detail-purge boundary purged_before, otherwise 400 outside_retention, to avoid wiping historical rollups using already-purged detail rows. Raising the retention period does not restore deleted detail rows, so the boundary only moves forward, never back. If reading the boundary fails or it is malformed, the rebuild errors out immediately without touching the rollups; the boundary check runs inside the rebuild transaction and is mutually exclusive with the purge goroutine) → {hours, rows}: rebuilds the hourly rollups from the raw call logs.
  • GET /api/admin/usage/reconcile?range= → {consistent, mismatches:[{hour, log_requests, rollup_requests, log_tokens, rollup_tokens}]}: hour-by-hour reconciliation.
  • GET /api/admin/usage/metering → {pending_bytes, overflow_records, dirty, dropped, replayed, write_failures, sync_failures, last_commit_at, purged_before, attempts_since}: metering journal status. overflow_records > 0, dirty staying true for a long time, or a growing sync_failures all indicate records that have not yet been persisted.

Metering reliability (stated conditionally, with no unconditional promises):

  • Write success: every call is appended to data/journal/calls.jsonl with a direct write(2), with no user-space buffering; once Record returns, the record is in the kernel page cache and survives a process crash.
  • Sync success: a separate sync goroutine fsyncs roughly once per second (it does not share a loop with ingestion or retry backoff); with YZAPI_JOURNAL_FSYNC=always every record is fsynced before it returns. A failed fsync keeps the pending-sync flag and retries on the next round, counted in sync_failures; on a device failure even always cannot guarantee that the data reached stable storage.
  • Failure degradation: when the journal is not writable, records are kept only in the in-memory overflow area (overflow_records) and are lost when the process exits; once the overflow area is full, records are dropped and counted in dropped.
  • Ingestion: a background process transactionally writes call_logs and updates the hourly rollups, advancing the checkpoint after commit; on restart the uncommitted part is replayed, and the uniqueness of request_id guarantees idempotence.
  • Request-level usage is always aggregated from the individual upstream attempts: known tokens are summed (including failed attempts and usage reported in error response bodies); if any attempt is unknown the request is unknown; a truncated stream is partial; only when no upstream was reached at all is it none; on client cancellation, none / unknown is decided by whether the request had already been written to the upstream.

Settings /api/admin/settings

  • GET → {basic, performance, vector, smart_route, compliance, elasticsearch} (secret fields masked as "******")
  • PUT /basic {base_url, log_retention_days, protocol_conversion, reasoning_to_content, site_name}. With reasoning_to_content enabled, responses converted across protocols into OpenAI Chat put the thinking content into the body as <think>…</think> instead of reasoning_content (streaming and non-streaming); same-protocol direct connections are unaffected
  • PUT /performance {max_concurrency, queue_size, queue_timeout_sec, request_timeout_sec, stream_idle_timeout_sec, max_body_kb, cooldown_sec, max_retries, upstream_connect_timeout_sec, max_body_memory_mb, vector_max_concurrency, vector_timeout_sec}; no field may be negative (400); passing 0 for max_retries / max_body_kb / max_body_memory_mb / vector_max_concurrency / vector_timeout_sec restores the default value
  • PUT /vector {account_id, model}; POST /vector/test {account_id, model} → {ok, dim, latency_ms, message}
  • PUT /smart_route {enabled, virtual_model, simple_group_id, complex_group_id, threshold, confidence_gap, top_k}
  • PUT /compliance {enabled, semantic_threshold, check_system_prompt, on_failure:"allow"|"block"}
  • PUT /elasticsearch {enabled,url,auth_type,api_key,username,password,index_prefix,request_kb,response_kb,retention_days}; POST /elasticsearch/test → {ok, version, message}; GET /elasticsearch/status → {configured, queue_count, queue_bytes, dropped, last_success_at, failing_since}
  • Passing "******" for a secret field means keep it unchanged.

Config versions /api/admin/config/snapshots

  • Before any write endpoint that modifies the account pool, model groups, price table or settings executes (probe-type endpoints excepted), a snapshot is saved automatically: accounts (including encrypted keys, stored in a separate persisted structure) and mappings, model groups, the user groups' model-group authorizations, the complete price table (an empty table is also a state), and every settings section; the payload carries version:2 and a sha256 checksum; at most 50 are kept.
  • GET → {items:[{id,actor,reason,created_at}], total}; POST {reason} takes a manual snapshot; GET /:id → summary (accounts without keys, with version and corrupt); POST /:id/restore → {restored, missing_keys[]}: first verifies the checksum (a mismatch returns 409 snapshot_corrupt without touching any data), then in one transaction overwrites the current configuration with the snapshot (after automatically saving the current state first), and then reloads the settings, prices, gateway snapshot, and the routing and compliance engines. v1 snapshots written by early 1.0.12 contain no keys: on restore, the current key of the account with the same id is reused; an account for which no key is available anywhere is restored in the disabled state, with a marker in its note, and listed in missing_keys. Users, user groups, keys and logs are out of scope. When the price list is restored as a whole, every row goes through the same validation as a manual add and is uniformly lower-cased; multiple rows with the same key are merged into one (edited > manual > earliest); the response additionally carries price_rows_skipped[] (invalid rows with their reasons) and price_rows_merged (the number of rows merged away), both of which the admin UI shows as warnings; after the restore, the price, gateway, vector, routing and compliance runtime states are all refreshed, a failure in one does not skip the others, and at the end a single 503 summarizes: when a single subsystem failed, code is that subsystem's code (price_reload_failed / gateway_reload_failed / compliance_reload_failed / route_reload_failed); when several failed, it is runtime_reload_failed; the body carries failed[]:{subsystem, code, error} and still carries restored / missing_keys / price_rows_*.
  • Uniform semantics of runtime refresh: every admin endpoint that first writes the database and then refreshes the in-memory copy (accounts, user groups, model groups, prices, policy groups / sensitive words / audit samples, routing samples, vector and compliance settings, snapshot restore) returns the 503 described above when the refresh fails, with a body stating "the data has been written to the database, but the runtime state was not refreshed; requests are still handled according to the previous configuration". The database write is not rolled back. The 503 body carries committed:true; create endpoints additionally carry the id and resource of the created resource. Do not resubmit the create request: the recovery action is POST /api/admin/runtime/reload (Settings → Config Snapshots → Reload Runtime (设置 → 配置快照 → 重新加载运行态), which refreshes the price, gateway, routing and compliance runtime states in full, and likewise returns the 503 above on failure) or restarting the gateway. Even if resubmitted, the create endpoints are idempotent, and the idempotency key is enforced by a database unique index (during the upgrade, within one transaction (under an advisory lock on PostgreSQL), legacy data is tidied up automatically and the unique index is created in the same transaction (by the time the function returns the key is already in effect; there is no "cleanup done, index not yet built" write window): accounts with duplicate names are renamed to <name> #<id> (checked against all names in use, no collisions, truncated at UTF-8 boundaries); duplicate sensitive words / samples are merged into one row — keeping the one with a valid state: the one whose vector identity can be loaded by the current runtime takes precedence — identity is resolved by exactly the same rule as at runtime (vector.Identity: one SQL statement reads the account's Base URL, type, enabled flag, key and the mapped upstream model at the same time, i.e. id|url|upstream; the runtime vector client is also constructed only from this single snapshot, so the key, URL and model necessarily belong to the same account generation). Base URLs are uniformly stripped of leading / trailing whitespace and the trailing slash (vector.NormalizeBaseURL); the client, the runtime identity and the migration use the same result. The identity the engine stamps on a stored vector is that of "the snapshot that produced the vector" (the embedding function returns it together with the result; a plain embedding function uses the identity read before the call), so a configuration switch while a request is in flight does not label old-model vectors with the new identity; every build first writes this build's claim token (build_token) onto the selected samples with one atomic update, and the write-back is a conditional update (id + the text_hash read at claim time + this build's token) whose affected-row count is checked — samples that were edited or deleted during the build, or re-claimed by a build that started later (regardless of whether its identity is the same or whether it has written yet), are counted in failed and not written. Ordering is adjudicated by the atomicity of the database claim update, which holds equally across processes / multiple instances; repeatable model identities no longer take part in ordering. Count conservation: the number of rows claimed by this build = built + failed, and this holds for every exit path — rows taken by a later build between the claim and the token-scoped read-back are counted in failed (they belong to the later build); when the upstream errors or returns the wrong number of vectors, the build stops immediately and the current batch plus every later batch that was already claimed but not yet sent are all counted in failed (they still carry this build's token and are claimed normally by the next build); rows written by earlier batches still count as built; nothing is ever counted as a silent success; when building by ID, the read-back is also restricted by primary key, with no full-table scan; claiming does not modify updated_at; an old empty identity is treated as compatible; next in precedence is any vector at all (the newest one on a tie), and then the enabled flag; if the vector settings are not valid JSON or a related query fails, the migration aborts without deleting any row. The four unique indexes are checked one by one by name against the database catalog to be "genuinely UNIQUE, with the correct columns, no WHERE clause and no expression columns, and every key column using the column's own default collation and ascending order" (SQLite uses PRAGMA index_list / index_xinfo and requires coll = BINARY, desc = 0; PostgreSQL queries pg_index restricted to the owning table and the current schema and requires indisvalid / indisready, indcollation equal to the column's attcollation, indclass being the type's default operator class, and indoption = 0; other dialects refuse to start) — keys are compared by exact string comparison, so a same-named index with NOCASE / RTRIM / DESC or the like is not it; other unrelated indexes on the same table (expression, partial, ordinary) neither participate nor are affected; a legacy index with the same name but the wrong definition (ordinary, wrong columns, partial, expression) is dropped and recreated in the same transaction; the enabled state is taken as "enabled if any copy is enabled"; the vector, a non-empty note and a non-zero threshold are merged over from the deleted copies; every deleted row is logged together with its vector identity): accounts are unique by name (uq_accounts_name); creating one again compares all effective fields (provider, account type, type, URL, key, protocol set, mapping set, test model, priority, weight, max concurrency, passthrough, enabled flag, note); if they are all identical the existing account is returned, if any differs 409 account_exists is returned with the existing id / resource; sensitive words are unique by (policy group, word), audit samples by (policy group, text SHA-256) and routing samples by (label, text SHA-256) (the text_hash column; on a digest collision the original text is compared); an identical one returns the existing row, a different note / enabled flag / threshold returns 409 word_exists / sample_exists with the existing resource; a duplicate sample with build_vector:true still runs the vector build against the existing row and reports under the same contract as a new creation (build_error / index-reload 503). The implementation is insert-first, adjudicated by the unique index; only after a key collision is the existing row read and compared; if that read fails, 500 is returned and no further insert is attempted; concurrent identical creates leave only one row. User groups, model groups and policy groups were already unique by name (409); price rows return 409 on their unique key. Batch import of routing samples (at most 2,000 per call, 400 above that): duplicates within the batch count as skipped; both the pre-check of keys and the post-write verification run as set queries in groups of 200 keys (300 rows take only a few SELECTs); samples that already exist with an identical note (the same comparison as a single create) count as existing and, with build_vector:true, are built together with the newly created rows; those with a different note count as conflicts and are left untouched; the insert uses ON CONFLICT DO NOTHING, created is the number of rows the database actually inserted; after the write all keys are verified, and rows inserted first by someone else are classified by the same rule into existing (and enter the build) or conflicts; if the write has committed but the verification query fails, 503 batch_reclassify_failed is returned (committed:true, created), no build is done, and the same batch can simply be resubmitted (idempotent; the verification and build are then completed). When editing an account name / sensitive word collides with a key and reading the conflicting resource fails, 500 is returned and no 409 is fabricated. Batch import of sensitive words is likewise idempotent (ON CONFLICT DO NOTHING), responding with created / skipped. Editing an account name or sensitive word into a unique-key collision returns 409 account_exists / word_exists with the conflicting resource; duplicate keys are recognized from the driver error code (SQLite 2067 / PostgreSQL 23505, via gorm TranslateError).
  • POST /api/admin/runtime/reload itself does not write the database: on failure it returns 503 runtime_reload_failed, with body committed:false and failed[], indicating that it can simply be retried.
  • Configuration generations for smart routing: the gateway snapshot stores the complete SmartRoute settings (virtual model, enabled flag, groups, threshold, rule parameters); the data plane and the routing engine read only the copy in the snapshot the request belongs to; when the snapshot rebuild fails after the settings are saved (503), the request keeps using the old generation as a whole, so a mix of "old snapshot + new groups" never occurs, and after a successful rebuild it switches over as a whole. The vector-build endpoints (create, update and batch build of routing samples / audit samples) behave the same way: when the vectors have been written to the database but the index reload fails, 503 is returned (route_reload_failed / compliance_reload_failed; the batch endpoints' body still carries built / failed), while a failure of the vector generation itself is still 200 plus build_error / error.
  • PUT /api/admin/settings/smart-route refreshes the gateway routing snapshot after saving (the complete smart-routing settings live in the snapshot); when the refresh fails, 503 gateway_reload_failed is returned; the settings are in the database but requests are still routed by the complete settings of the old snapshot.
  • Rule loading at startup: on startup the gateway performs one checked load each for the smart-routing and compliance engines. If compliance is enabled and reading the rules fails, startup is refused; if compliance is not enabled, a warning is logged. Before its first successful load the compliance engine is in the "not loaded" state; if compliance is enabled at that time, every request is treated as blocked (DetectMethod:"unavailable", Degraded with the reason); requests are never let through because the rules could not be read.
  • Creating a price / user group / account / policy group / sensitive word / audit sample with enabled:false: the insert and the disable are in the same transaction; if the second step fails the whole thing is rolled back, leaving no enabled row behind. Price notes and import notes are truncated at UTF-8 character boundaries (255 / 250 bytes) and never produce invalid UTF-8.

Pricing /api/admin/prices

  • Object: {id, pattern, provider, input_per_m, output_per_m, cached_input_per_m, cache_write_per_m, currency:"USD"|"CNY", builtin, enabled, note, source, source_date, edited, updated_at} (an empty source means a built-in or manual row; edited is set to true once an admin has modified the row); all unit prices are per million tokens. pattern matches the model name exactly or as a prefix (separators -, :, @), and longer patterns take precedence; an empty provider means any provider. Match order: the row specific to the account's provider → the any-provider row → rows of other providers (so claude/gpt models forwarded through a custom relay can also be priced).
  • GET ?q= → {items, total, builtin_updated, currency}; POST, PUT /:id, DELETE /:id; POST /reset-builtin restores the built-in reference prices (custom rows are kept); GET /lookup?provider=&model= → {found, price}.
  • The built-in table is updated with each release; on startup only missing built-in rows are added, and edited rows are never overwritten.
  • Price-list sync is split into two steps, preview and apply; apply writes only the content that was previewed:
    • POST /api/admin/prices/import {source:"litellm"|"easycpa"|"url", url?, overwrite_edited, overwrite_currency}: downloads and previews, without writing the database or taking a config snapshot. With source litellm it downloads LiteLLM's model_prices_and_context_window.json; with easycpa, EasyCLIProxyAPI's model_prices.json (models is an object keyed by model name; the older array form is also accepted); with url, a custom address (32 MB limit, 60-second timeout, through the gateway's outbound HTTP client and proxy settings). Response {applied:false, plan_id, sha256, origin, options, expires_in, plan}; plan_id is valid for 30 minutes and is invalidated after one use; the server keeps at most 16 pending previews / 50,000 rows in total (in-process memory; multi-instance deployments need session affinity). These two caps are hard limits: a single catalog exceeding 50,000 rows returns 413 import_too_large; to make room for a new preview only the oldest previews that are not currently being applied are evicted, and when all of them are being applied 429 import_busy is returned; claiming (apply) only cleans up expired entries and never evicts a valid preview just because the cache happens to be full. Passing apply:true returns 400.
    • POST /api/admin/prices/import-file: multipart upload of a file in the same format for preview; fields file, overwrite_edited, overwrite_currency.
    • POST /api/admin/prices/import/apply {plan_id, sha256?}: writes the catalog parsed from that preview (no re-download); a config snapshot is taken before applying; the plan is recomputed inside the transaction against the table as it is at that moment, and the plan counts in the response are the actual write results. Claiming a plan_id is atomic: of concurrent applies with the same plan_id only one succeeds and the rest get 409; if the snapshot or the write transaction fails, the plan_id is released for retry, and it is consumed only on a successful write. An expired / already used / currently applying plan_id, or a mismatched sha256, returns 409. If the database write succeeds but the runtime price-list reload fails, 503 price_reload_failed is returned (the database already has the new prices, requests are still billed at the old prices; retry or restart the gateway).
    • plan: {source, date, total, new, updated, same, kept, skipped, invalid, invalid_rows?[], duplicates, changes:[{action:"new"|"update"|"keep", pattern, provider, old?:[in,out,cached,write], new:[...], old_currency?, new_currency, currency_changed?, reason?:"manual"|"currency"}]} (changes holds at most 500 entries).
    • Rules: every row goes through the same validation as a manual add (name 1–128 characters, provider ≤32, currency USD/CNY, the four unit prices 0–1000000 and finite); rows that fail are counted in invalid with their reasons listed and are not written; rows added manually by an admin (builtin=false and source="") or edited manually (edited=true) are kept by default (reason:"manual") and overwritten only with overwrite_edited=true; an existing row whose currency differs from the source (typically a domestic provider's CNY built-in row against LiteLLM's USD international price) is kept by default (reason:"currency", the change row carries the old and new currency) and changed only with overwrite_currency=true; the remaining built-in rows and previously imported rows are updated in place (the builtin flag is kept, so "Reset built-in" (恢复内置) can still restore the official reference prices); when the current table has several rows for the same (provider, model), apply merges them into one (edited rows first, then manual rows, then the earliest), and the preview reports the count in duplicates. LiteLLM imports only the entries that can be mapped to a provider of this gateway (openai / anthropic / gemini / xai / deepseek / moonshot / dashscope→aliyun / volcengine / minimax / zhipu / mistral / groq / together / fireworks / cerebras); resellers (bedrock / azure / openrouter etc.) are skipped, non-text modes are skipped, and the provider prefix is stripped from the model name; EasyCLIProxyAPI rows carry no provider information and are imported as generic rows. Imported rows record their origin in source / source_date; source unit prices are always USD.
  • The price-row key (provider, pattern) is unique and stored uniformly lower-cased (unique index uq_model_prices_key; during the upgrade legacy rows are lower-cased first and de-duplicated by the precedence above); a create / update that collides with the key returns 409 price_exists. Price CRUD and "Reset built-in" (恢复内置) likewise return 503 price_reload_failed when the runtime reload fails after the database write.
  • PUT /api/admin/settings/pricing {currency:"CNY"|"USD", usd_to_cny}: display currency and exchange rate. The ledger is fixed in USD: CNY unit-price rows are converted to USD at the exchange rate when booked, and converted to the display currency at display time; switching the display currency or the rate only changes the display, historical amounts are converted along with it as a whole, and are never relabeled or summed across mixed currencies. GET /api/public/info, the price list and the usage reports all return the current currency; the price list additionally carries ledger:"USD".
  • Cost is estimated at the end of every attempt using the unit prices in effect at that time and then frozen: call_logs.cost_micros (in millionths of the USD ledger unit; cost_ledger:"USD" marks the row as booked in the ledger; unmarked legacy rows are classified row by row by the startup migration and journal replay according to the database's origin and each handled once; those that cannot be classified are marked "unverified": the original value stays in cost_micros, cost_known=false, the log's cost is 0 with cost_unverified=true, the hourly rollup excludes their amount and only counts them in cost_unverified, and the usage report gives the counts in summary.cost_unverified and each breakdown item's cost_unverified; the reconciliation result Mismatch gains log_cost / rollup_cost / log_unverified / rollup_unverified), and cost_known (false if any attempt with tokens has no unit price, or any attempt's usage is unknown / partial: the amount is then only a lower bound of the known part); the hourly rollup's cost_micros is attributed by attempt account. The admin log list / detail and the user logs all carry cost converted to the display currency; the usage reports carry summary.cost, each breakdown item's cost, the trend points' cost and currency; the overview carries cost.total. Input tokens are priced in three tiers: cache read (cached_tokens, at the cached price), cache write (cache_write_tokens, from Anthropic's cache_creation_input_tokens, at cache_write_per_m, or at the input price when that row leaves it empty), and the rest at the input price. Changing unit prices does not retroactively alter historical records.

Backup & restore /api/admin/backups

  • An archive is a tar.gz: manifest.json (format:1, app_version, created_at, db_driver:"sqlite", files with a SHA-256 per file) plus data/db/yzapi.db, data/journal/*, data/security/credential.key (the key encrypting upstream keys) and jwt.key. Consistency: the journal and secrets are first copied into private files (checksummed while copying), then the running database is snapshotted with VACUUM INTO, and only those copies are archived; the metering writer records "journal generation:committed offset" in the database in the same transaction as every commit (settings row journal_state, not part of config snapshots), so a database snapshot carries its own exact replay boundary; the archived checkpoint is that boundary (clamped to the copy's last complete line): records before it are in the snapshot and are not replayed, records after it are replayed on restore. The journal copy and the generation read happen under the rotation lock; if the generation moved between the copy and the snapshot, the copy was rotated away (rotation only follows a full commit) and is treated as committed. A database without a recorded boundary archives checkpoint 0 and relies on request_id deduplication. Metering durable before the backup is restored exactly once, historical records whose raw rows retention already purged are never added to the hourly rollups again, and appends, commits, checkpoint moves and rotations during packing never change archived bytes. Default-path embedded SQLite only (data/db/yzapi.db in the data directory): on PostgreSQL, or with a custom SQLite path in YZAPI_DB_DSN, the list answers supported:false with unsupported_reason, and create, restore, enabling automatic backups and the CLI restore answer 400 backup_unsupported; use pg_dump or back up that database file yourself, and keep data/security separately. An archive contains what is needed to decrypt every upstream key; download and store it like a secret.
  • GET → {items:[{name,size,created_at}], pending_restore, db_driver, supported} (local archives under backups/ in the data directory, newest first); POST writes one now → {name,size,created_at}, named yzapi-backup-<YYYYMMDD-HHMMSS>-<version>.tar.gz; GET /:name/download (application/gzip); DELETE /:name. An invalid name is 400, a missing one 404.
  • POST /restore (multipart field file, 2 GB upload cap, 16 GB total unpacked): each upload is unpacked into its own private data/restore-incoming-* directory and validated (manifest present, same format version, paths confined to data/db/yzapi.db, data/journal/*, data/security/*, every manifest entry present with a matching checksum, no file the manifest does not list, database and credential.key present, the database opens read-only, has a users table and passes integrity_check); any failure is 400 backup_invalid, no existing file is touched and a restore already waiting is unaffected. A valid archive is published as data/restore-staging/ with a single rename; only one restore can wait at a time and another valid upload answers 409 restore_pending (of concurrent uploads exactly one wins, contents never mix). On success it answers {staged:true, restart:"scheduled", backup:manifest} and about 1.5 s later the process sends itself SIGTERM for a graceful exit; the container restart policy / systemd Restart=always brings it back, and the next start swaps the staged files in before the database is opened, moving the replaced data/db, data/journal and data/security to data/pre-restore-<time>/. The swap resumes after an interruption at any step: a directory still staged has not been installed (the live one is set aside first), a directory no longer staged was installed and is left alone, and the set-aside directory name is recorded in the staging area; the pending marker is removed only after the database and credential.key are verified in place. If that check fails the gateway refuses to start and logs the staging and set-aside locations instead of initialising an empty database over a half-applied restore. After a restore the login password is the one valid when the backup was taken.
  • CLI: yzapi -restore <archive> restores into a stopped instance or a fresh server and exits (same validation and swap); start normally afterwards.
  • PUT /api/admin/settings/backup {enabled, hour_local:0-23, keep_count:0-365}: daily policy; one archive is written into backups/ at hour_local server local time (skipped when an archive already exists for that hour, so a restart does not write a second one), then only the newest keep_count are kept (0 keeps all); enabling it on PostgreSQL is 400 backup_unsupported. The backup section of GET /api/admin/settings returns the policy.

System

  • GET /api/admin/system/info → {version, go_version, db_driver, uptime_sec, started_at, data_dir}

User center /api/user/* (any logged-in user)

  • GET /api/user/models → {base_url, models:[{name,type,kind,provider}]} (only the models available to the user's own group); models of type custom also carry endpoints:[] (the /v1/<path> list on which the model can be called, the union over the enabled accounts mapping it; the user console guide renders its examples from it).
  • GET /api/user/keys → [{id,name,prefix,suffix,masked:"sk-abcd…wxyz",enabled,last_used_at,created_at}]
  • POST /api/user/keys {name, expires_at?, allowed_models?[], tokens_per_minute?, requests_per_minute?} → {key:"sk-full plaintext (returned only this once)", item:{...}}. expires_at null means never expires; allowed_models may only contain models or model-group names visible to the user, and empty means inherit from the user group; a per-minute cap of 0 means unlimited, measured over the last 60 seconds. The key object also contains expires_at, expired, allowed_models, tokens_per_minute, requests_per_minute
  • PUT /api/user/keys/:id same fields as POST; PATCH /api/user/keys/:id/enabled {enabled}; DELETE /api/user/keys/:id
  • GET /api/user/usage?api_key_id=&model=&api_type=&range=&from=&to=&group_by= → same structure as the admin usage response (only by_model / by_api_key / by_provider)
  • GET /api/user/logs?api_key_id=&model=&result=&range=&q=&page= → same as the call-log object (redacted, without account information)
  • GET /api/user/group → {id,name,max_concurrency,key_max_concurrency,token_quota,tokens_used_month,model_groups:[{id,name,models[]}]}