English version of api.md. If the two differ, the Chinese file is authoritative.
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-keyandx-goog-api-keyare accepted, as is the Gemini REST-style?key=query parameter; paths with/v1omitted 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-lateststripped, and with version / date suffix tolerance (claude-sonnet-4-5-20250929hits the mappingclaude-sonnet-4-5, and vice versa; only numeric or date-shaped suffixes are accepted,gpt-5-codexdoes not hitgpt-5); logs and reports record the resolved name. Ambiguity (multiple candidates of the same length, or multiple mappings that differ only in case) returns 400model_ambiguous; the upstream is not called and the request does not fall through to passthrough. - Passthrough of unmapped models: once an account has
passthrough_modelsenabled, 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) andPOST /v1/images/variationsalike; no second account or mapping is needed for image-to-image.edits/variationsacceptmultipart/form-data(OpenAI shape: one or moreimage/image[]files, optionalmask,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/editsetc.: every part keeps its field name, filename, Content-Type and bytes, onlymodelis replaced by the mapped upstream name (a missingmodelis 400missing_model, a broken form 400invalid_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 frominput_tokens_details.cached_tokens) orprompt_tokens/completion_tokens(cached detail fromprompt_tokens_details.cached_tokens) from the reply'susageare 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 nousage(per-image billing) stay unknown / unpriced. - Custom JSON endpoints (non-chat models): an account of type
customdeclares the client paths it serves (endpoints, relative to/v1, e.g./systemone). ClientsPOST /v1/<path>with a JSON body that must carrymodel; after authentication, model resolution (mapping or passthrough; the model's type must becustom), group authorisation, rate limits, concurrency and account health, the gateway forwards the body verbatim to<account base URL>/<path>(onlymodelis rewritten to the mapped upstream name; the account key is injected asAuthorization: Bearer, plusx-api-keywhen the provider'sauth_headersays 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 theX-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'susageasprompt_tokens/completion_tokensorinput_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 ausageblock with none of the four fields is unknown (an error reply withoutusagestill follows the status-code rule);total_tokensis 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/v1segment (/v1/xand/v1/v1/xare both stored as/x, so an endpoint's own first segment can never bev1;/v1beta/xand/v10/xare 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 404not_foundafter key authentication (401 without a valid key, so the existence of a path is not disclosed); acustommodel on the chat / embeddings / images entry points, or any other type on a custom path, answers 400model_type_mismatch. Built-in presets:typesafe(TypeSafe Jev, base URLhttps://api.typesafe.ai/v1, default path/systemone) andcustom-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-betapassed through); otherwise the count is estimated from the request body size and returned withX-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 includeshas_more; the gateway's own classification is inmodel_type(text / embedding / image), andkindis 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-Afteris passed through to the client. - Client identification: every log row records
client(a short label identified fromUser-Agentandx-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 ofRefereris 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 yieldsunknown) anduser_agenttruncated to 200 characters. Admin logs can be filtered byclient, andGET /api/admin/logs/filtersreturns theclients[]that have appeared; usage reports (admin and user center) includeby_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, withusageMetadatadelivered in the last event); errors are returned in Google's{"error":{"code","message","status"}}shape (UNAUTHENTICATED/PERMISSION_DENIED/NOT_FOUND/RESOURCE_EXHAUSTED…);countTokensgoes 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, somodelis not injected) and onlyx-goog-api-keyis 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,thoughtparts ↔ reasoning,thinkingBudget↔reasoning_effort,responseMimeType/responseSchema↔response_format, andthoughtsTokenCountis 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.
| 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 |
| 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}
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}]}
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}]?}]. Whenaccount_types[].protocolsis present it means that entry point only speaks those protocols (for example the Anthropic-compatible entry points of DeepSeek / Kimi / Zhipu / MiniMax / Bailian only speakanthropic-messages; the Gemini preset's "native Gemini API" entry point only speaksgemini-generate, while its "OpenAI-compatible" entry point speaksopenai-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/discovermay carryaccount_type/protocols; the native Gemini entry point fetchesmodels[].namewithx-goog-api-keyand strips themodels/prefixendpointsanddefault_mappingsappear 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 appliesdefault_mappingswhen a request carries no mappings and passthrough is off, and the presetendpointswhen none are given.GET /api/admin/models→[{name,type,kind:"model"|"virtual"|"group",provider,accounts,models[]}](every request model currently routable)
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/accountsbody:{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/:idPUT /api/admin/accounts/:idsame as POST; an emptyapi_keymeans keep the existing one.typecannot be changed.- Scheduling order: accounts are first tiered by
priorityin ascending order; within one tier they are ordered by weighted random onweight(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-Protocolheaders, 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 cooldownPOST /api/admin/accounts/:id/test-model{model}→{ok, latency_ms, message, model}, probes the given upstream model with the stored credentialsPOST /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 (withcache_controlon 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 modelPUT /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/testsame body as POST account (may carryaccount_id) →{ok, latency_ms, message, model}
Object: {id, name, type, models:["a","b"], note, created_at, updated_at, in_use_by_route:bool, route_roles:["simple"|"complex"]}
GET(supportstype=,q=),POST,GET /:id,PUT /:id,DELETE /:id(returns 409 when referenced by smart routing)
GET ?role=&group_id=&enabled=&q=&page=; each item is theuserobject +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 /:idPATCH /:id/enabled {enabled}POST /:id/reset-password {password}POST /:id/unlockLast-admin protection: disabling / demoting / deleting the last admin returns 409code:"last_admin".
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: 409has_members; default group: 409is_default),PATCH /:id/enabled
- 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 andbuild_vectoris true, the vector is rebuilt synchronously; on failure the response carriesbuild_error);DELETE /samples/:idPOST /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}]}
- 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)
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 /:idGET /api/admin/logs/filters(also returnsclients[]: 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_msis 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_msis 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_msis the whole request;queue_wait_msis 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_msruns from sending to the upstream until forwarding ends (including the interleaved client writes when streaming);client_write_msis 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_msis 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_msapproximates the gateway's pre-processing plus the upstream's first packet. Only samples that succeeded and actually wrote content satisfyqueue_wait_ms ≤ first_byte_ms ≤ first_content_ms ≤ latency_ms;first_byte_msof failed requests,first_content_msof 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) andest_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 ofattempts[]carriesusage_status,prompt_tokens,completion_tokens.usage_correctedbeing 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.
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.
- The usage response's
summaryand every breakdown item containunknown_usage(the number of requests whose usage is partial / unknown); every breakdown item additionally containsattempts. 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 tableusage_hourlieslikewise has anattemptscolumn; regular ingestion and rebuild use the same aggregation function. Upgrade compatibility: on the first start with the new version, NULLattemptsare backfilled once and the hours within the retention period whose detail rows are still complete are rebuilt under the new semantics (recorded inmetering.attempts_since); hours earlier than that time never recorded attempt counts, so anattemptsof 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 flaggedusage_corrected; processing them again does not count them twice. The portion of the request-leveltotal_tokensthat exceeds the sum of input + output over all attempts (tokens the provider reports outside input and output, preserved during correction) is recorded intotal_tokensof the final-answer account's row and is not disguised as input or output; the aggregatedtotal_tokenstherefore always equals the request'stotal_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;frommust not be earlier than the call-log retention period, nor earlier than the persisted detail-purge boundarypurged_before, otherwise 400outside_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,dirtystaying true for a long time, or a growingsync_failuresall 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.jsonlwith a direct write(2), with no user-space buffering; onceRecordreturns, 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=alwaysevery record is fsynced before it returns. A failed fsync keeps the pending-sync flag and retries on the next round, counted insync_failures; on a device failure evenalwayscannot 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 indropped. - Ingestion: a background process transactionally writes
call_logsand updates the hourly rollups, advancing the checkpoint after commit; on restart the uncommitted part is replayed, and the uniqueness ofrequest_idguarantees 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.
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}. Withreasoning_to_contentenabled, responses converted across protocols into OpenAI Chat put the thinking content into the body as<think>…</think>instead ofreasoning_content(streaming and non-streaming); same-protocol direct connections are unaffectedPUT /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 formax_retries / max_body_kb / max_body_memory_mb / vector_max_concurrency / vector_timeout_secrestores the default valuePUT /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.
- 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:2and a sha256checksum; 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, withversionandcorrupt);POST /:id/restore→{restored, missing_keys[]}: first verifies the checksum (a mismatch returns 409snapshot_corruptwithout 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 inmissing_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 carriesprice_rows_skipped[](invalid rows with their reasons) andprice_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,codeis that subsystem's code (price_reload_failed/gateway_reload_failed/compliance_reload_failed/route_reload_failed); when several failed, it isruntime_reload_failed; the body carriesfailed[]:{subsystem, code, error}and still carriesrestored/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 theidandresourceof the created resource. Do not resubmit the create request: the recovery action isPOST /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+ thetext_hashread 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 infailedand 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 infailed(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 infailed(they still carry this build's token and are claimed normally by the next build); rows written by earlier batches still count asbuilt; 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 modifyupdated_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 usesPRAGMA index_list/index_xinfoand requirescoll = BINARY,desc = 0; PostgreSQL queriespg_indexrestricted to the owning table and the current schema and requiresindisvalid/indisready,indcollationequal to the column'sattcollation,indclassbeing the type's default operator class, andindoption = 0; other dialects refuse to start) — keys are compared by exact string comparison, so a same-named index withNOCASE/RTRIM/DESCor 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 409account_existsis returned with the existingid/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) (thetext_hashcolumn; on a digest collision the original text is compared); an identical one returns the existing row, a different note / enabled flag / threshold returns 409word_exists/sample_existswith the existing resource; a duplicate sample withbuild_vector:truestill 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 asskipped; 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 asexistingand, withbuild_vector:true, are built together with the newly created rows; those with a different note count asconflictsand are left untouched; the insert usesON CONFLICT DO NOTHING,createdis 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 intoexisting(and enter the build) orconflicts; if the write has committed but the verification query fails, 503batch_reclassify_failedis 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 withcreated/skipped. Editing an account name or sensitive word into a unique-key collision returns 409account_exists/word_existswith the conflicting resource; duplicate keys are recognized from the driver error code (SQLite 2067 / PostgreSQL 23505, via gormTranslateError). POST /api/admin/runtime/reloaditself does not write the database: on failure it returns 503runtime_reload_failed, with bodycommitted:falseandfailed[], indicating that it can simply be retried.- Configuration generations for smart routing: the gateway snapshot stores the complete
SmartRoutesettings (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 carriesbuilt/failed), while a failure of the vector generation itself is still 200 plusbuild_error/error. PUT /api/admin/settings/smart-routerefreshes the gateway routing snapshot after saving (the complete smart-routing settings live in the snapshot); when the refresh fails, 503gateway_reload_failedis 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",Degradedwith 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.
- 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 emptysourcemeans a built-in or manual row;editedis set to true once an admin has modified the row); all unit prices are per million tokens.patternmatches the model name exactly or as a prefix (separators-,:,@), and longer patterns take precedence; an emptyprovidermeans 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-builtinrestores 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. Withsourcelitellmit downloads LiteLLM'smodel_prices_and_context_window.json; witheasycpa, EasyCLIProxyAPI'smodel_prices.json(modelsis an object keyed by model name; the older array form is also accepted); withurl, 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_idis 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 413import_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 429import_busyis returned; claiming (apply) only cleans up expired entries and never evicts a valid preview just because the cache happens to be full. Passingapply:truereturns 400.POST /api/admin/prices/import-file: multipart upload of a file in the same format for preview; fieldsfile,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 theplancounts in the response are the actual write results. Claiming aplan_idis atomic: of concurrent applies with the sameplan_idonly one succeeds and the rest get 409; if the snapshot or the write transaction fails, theplan_idis released for retry, and it is consumed only on a successful write. An expired / already used / currently applyingplan_id, or a mismatchedsha256, returns 409. If the database write succeeds but the runtime price-list reload fails, 503price_reload_failedis 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"}]}(changesholds 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
invalidwith their reasons listed and are not written; rows added manually by an admin (builtin=falseandsource="") or edited manually (edited=true) are kept by default (reason:"manual") and overwritten only withoverwrite_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 withoverwrite_currency=true; the remaining built-in rows and previously imported rows are updated in place (thebuiltinflag 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 induplicates. 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 insource/source_date; source unit prices are always USD.
- The price-row key
(provider, pattern)is unique and stored uniformly lower-cased (unique indexuq_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 409price_exists. Price CRUD and "Reset built-in" (恢复内置) likewise return 503price_reload_failedwhen 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 currentcurrency; the price list additionally carriesledger:"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 incost_micros,cost_known=false, the log'scostis 0 withcost_unverified=true, the hourly rollup excludes their amount and only counts them incost_unverified, and the usage report gives the counts insummary.cost_unverifiedand each breakdown item'scost_unverified; the reconciliation resultMismatchgainslog_cost / rollup_cost / log_unverified / rollup_unverified), andcost_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'scost_microsis attributed by attempt account. The admin log list / detail and the user logs all carrycostconverted to the display currency; the usage reports carrysummary.cost, each breakdown item'scost, the trend points'costandcurrency; the overview carriescost.total. Input tokens are priced in three tiers: cache read (cached_tokens, at the cached price), cache write (cache_write_tokens, from Anthropic'scache_creation_input_tokens, atcache_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.
- An archive is a tar.gz:
manifest.json(format:1,app_version,created_at,db_driver:"sqlite",fileswith a SHA-256 per file) plusdata/db/yzapi.db,data/journal/*,data/security/credential.key(the key encrypting upstream keys) andjwt.key. Consistency: the journal and secrets are first copied into private files (checksummed while copying), then the running database is snapshotted withVACUUM 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 rowjournal_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 checkpoint0and relies onrequest_iddeduplication. 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.dbin the data directory): on PostgreSQL, or with a custom SQLite path inYZAPI_DB_DSN, the list answerssupported:falsewithunsupported_reason, and create, restore, enabling automatic backups and the CLI restore answer 400backup_unsupported; usepg_dumpor back up that database file yourself, and keepdata/securityseparately. 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 underbackups/in the data directory, newest first);POSTwrites one now →{name,size,created_at}, namedyzapi-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 fieldfile, 2 GB upload cap, 16 GB total unpacked): each upload is unpacked into its own privatedata/restore-incoming-*directory and validated (manifest present, same format version, paths confined todata/db/yzapi.db,data/journal/*,data/security/*, every manifest entry present with a matching checksum, no file the manifest does not list, database andcredential.keypresent, the database opens read-only, has auserstable and passesintegrity_check); any failure is 400backup_invalid, no existing file is touched and a restore already waiting is unaffected. A valid archive is published asdata/restore-staging/with a single rename; only one restore can wait at a time and another valid upload answers 409restore_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 / systemdRestart=alwaysbrings it back, and the next start swaps the staged files in before the database is opened, moving the replaceddata/db,data/journalanddata/securitytodata/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 andcredential.keyare 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 intobackups/athour_localserver local time (skipped when an archive already exists for that hour, so a restart does not write a second one), then only the newestkeep_countare kept (0 keeps all); enabling it on PostgreSQL is 400backup_unsupported. Thebackupsection ofGET /api/admin/settingsreturns the policy.
GET /api/admin/system/info→{version, go_version, db_driver, uptime_sec, started_at, data_dir}
GET /api/user/models→{base_url, models:[{name,type,kind,provider}]}(only the models available to the user's own group); models of typecustomalso carryendpoints:[](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_atnull means never expires;allowed_modelsmay 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 containsexpires_at, expired, allowed_models, tokens_per_minute, requests_per_minutePUT /api/user/keys/:idsame fields as POST;PATCH /api/user/keys/:id/enabled {enabled};DELETE /api/user/keys/:idGET /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[]}]}