/** * Shared data model for MyAPIKey. * One config object, persisted as a single JSON file (data.json). */ /** Wire format a backend speaks (and that we expose on the matching path). */ export type Format = "openai" | "anthropic"; /** A backend / subscription platform / direct provider. */ export interface Provider { id: string; // prv_ name: string; // human label, also used as CLI handle /** OpenAI-family base INCLUDING the version segment, e.g. https://api.openai.com/v1 * or Ark's /api/v3. Used for /chat/completions (and /models discovery). */ baseUrlOpenai: string; /** Anthropic base EXCLUDING /v1, e.g. https://api.anthropic.com or Ark's /api/coding. * The gateway appends /v1/messages (and /v1/models for discovery). */ baseUrlAnthropic: string; /** OpenAI-family base for the Responses API, INCLUDING the version segment. * Independent of baseUrlOpenai — a backend may serve /responses at a * different address (or serve ONLY responses). The gateway appends * /responses (and /models for discovery). Empty = responses not served. */ baseUrlResponses: string; apiKey: string; /** Which routing formats this backend responds to (one per agent surface). */ formats: RouteKey[]; /** Optional request-per-minute cap (RPM pacing). When set, dispatch skips this * source once it has forwarded `rpm` calls in the trailing 60s window — the * request fails over to the next source instead of racing the upstream's own * rate limit (and burning a free/quota-bound key). 0/absent = unlimited. * The limit is on the key, so it's per-source and shared across every model * routed through it. Tracked in-memory only (see Store.rpmUsed). */ rpm?: number; /** Model ids this provider offered at last discovery (cached, may be stale). */ discoveredModels?: string[]; /** Manually supplemented upstream model ids — names a backend's /models list * doesn't include but that still work (delisted, unlisted, behind a * different endpoint). Merged with `discoveredModels` for editor suggestions * and the UI's staleness check; discovery refreshes never touch this list. */ extraModels?: string[]; /** Epoch ms of the last successful/attempted discovery. */ discoveredAt?: number; createdAt: number; } /** One routing slot: an independent enable flag + a priority-ordered chain of * (provider, optional upstream model) pairs. Invariant (enforced by admin * mutations, defended by proxy candidates()): every id in `providers` exists in * `GateConfig.providers` and is compatible with the slot — it must carry that * format in `formats` (with the matching base URL filled in). * * A provider id may appear MORE THAN ONCE — each occurrence is an independent * failover slot that can carry its own upstream model name. When forwarding to * a slot, if its `model` is set the gateway rewrites the request's `model` * field to that value before the passthrough POST; otherwise the public model * name (the key in GateConfig.models) is sent verbatim. That is a pure name * rewrite on the passthrough body — still no OpenAI↔Anthropic translation. It * lets you alias (claude-sonnet-4 → claude-sonnet-4-20250514), swap the actual * model (gpt-4 → gpt-4o) per source, or fail over across several models on ONE * backend (Ark → doubao-pro primary, Ark → doubao-lite fallback). Absent model * = identity (send the public name). */ export interface FormatEntry { enabled: boolean; /** Ordered chain of routing slots: first = primary, rest = fallback. The same * provider id may repeat — each occurrence can map a different upstream model. */ providers: ChainSlot[]; } /** One slot in a format's provider chain: which provider to forward to, and the * optional upstream model name to rewrite the request's `model` field to. * Duplicates of `id` are legal (distinct failover slots). */ export interface ChainSlot { id: string; /** Upstream model name to send to this provider. Absent = forward the public * model name (the key in GateConfig.models) unchanged. */ model?: string; /** Default thinking level for this slot. When set it takes PRECEDENCE over * the request's own thinking parameters — dispatch replaces them outright * (the gateway's configured level is the authority). When absent, the * request's parameters pass through untouched. The value is whatever the * slot's wire format takes natively — no translation: an effort token * ("low"/"medium"/"high"/…) on openai/responses slots, a thinking budget * in tokens (positive integer) on anthropic slots. */ thinking?: string; /** Default sampling parameters for this slot. Unlike thinking (which owns * EVERY thinking switch, since a forced level plus a stale client switch * would contradict), each configured field here replaces ONLY the request's * same-named value — an unconfigured field passes through untouched. * temperature=0.2 with the client's own top_p intact is coherent, so the * override is per-field. Keys are the wire-agnostic names (temperature, * top_p, top_k, presence_penalty, frequency_penalty, seed) — identical on * all three wires, so no per-format dialect. Absent = pure passthrough. */ sampling?: Record; } /** A model's routing dimensions — one per forwarding endpoint. The three are * fully independent surfaces with independent per-source base URLs: a source * can serve any subset (responses-only and chat-only are both legal), and the * same source can sit in several chains at once. */ export type RouteKey = "openai" | "anthropic" | "responses"; /** Per-model routing entry, keyed by model name (e.g. "gpt-4o"). */ export interface ModelEntry { openai: FormatEntry; anthropic: FormatEntry; responses: FormatEntry; /** Even-pacing rate limit (requests/min, leaky-bucket style). When set, calls * to this model are released one every 60/rpm seconds - excess requests QUEUE * at the gateway (up to 60s wait) instead of overflowing. Completely * independent of Provider.rpm (that one skips a busy source and fails over); * this one paces the model across all three route slots. Absent = unlimited. */ paceRpm?: number; /** Debug capture (pure debugging aid, never on by default). When true, dispatch * records every UPSTREAM ATTEMPT for this model — the exact forwarded request * body and the response body — into an in-memory ring buffer (last 50, see * Store.pushCapture). Turning it off clears the buffer; restarts clear it too. * Independently of this switch, FAILED attempts always land in a global * in-memory net (last 50 across all models) so an error can be inspected * after the fact. Nothing here ever touches logs.jsonl. */ debugCapture?: boolean; } export interface Account { username: string; /** `null` until the user creates the password from the first-login setup form. */ password: string | null; } export interface GateConfig { version: number; account: Account; /** Secret agents use to call /v1 (Bearer / x-api-key). Separate from the account password. */ apiKey: string; providers: Provider[]; /** modelName -> routing entry. */ models: Record; } /** Token usage for a single call. Captured from the upstream's reported usage * when available — that's the exact billed count (Anthropic streams, non- * streaming bodies, /responses all carry it). For OpenAI /chat/completions * streams where the upstream omits usage (most agents don't set * stream_options.include_usage), `estimated` is set and input/output come from * a local tokenizer approximation (gpt-tokenizer, o200k_base) instead — the UI * renders those with a ≈ marker. cacheRead/cacheCreation (prompt-cache hit / * write tokens) are surfaced separately from `input`, which always means * UNCACHED prompt tokens: Anthropic reports them that way natively, while on * the OpenAI-family wires the cached subset (chat `prompt_tokens_details. * cached_tokens`, DeepSeek `prompt_cache_hit_tokens`, /responses * `input_tokens_details.cached_tokens`) arrives INCLUDED in prompt_tokens / * input_tokens and is subtracted at capture time to match. */ export interface Usage { input: number; output: number; cacheRead?: number; cacheCreation?: number; estimated?: boolean; } /** One persisted call-history entry (stored in logs.jsonl, one JSON object per line). */ export interface LogEntry { ts: number; model: string; /** The actual upstream model name forwarded to the provider, when a per-slot * rewrite changed it (a ChainSlot `model` that differs from the public name — * an alias, a model swap, or one of several models on a repeated source). * Absent when the public name was forwarded verbatim, so identity + legacy * rows stay clean. Lets the log show which real model answered when a source * remaps the public name. */ upstreamModel?: string; provider: string; /** Stable provider id (newer entries). Stats group by this so renaming a * provider doesn't split its history; the display name is resolved from the * live config at read time. Absent on legacy lines → fall back to `provider`. */ providerId?: string; format: RouteKey; status: number; /** End-to-end latency (ms): from the dispatch start to the returned response. */ ms: number; /** Whether the request asked for streaming. */ stream: boolean; /** Token usage for this call (success rows only). Absent when the upstream * reported none AND no local estimate was possible (e.g. a failed/truncated * stream), or on legacy log lines written before usage tracking. */ usage?: Usage; /** Short upstream error text on non-2xx (omitted on success). */ error?: string; /** The thinking level this call ran with, and where it came from: "client" = * the answering slot had no default, so the request's own thinking setting * ran (forwarded untouched); "default" = the slot's configured `thinking` * ran, OVERRIDING whatever the request carried. Absent when neither * applied (no default configured, request carried nothing). */ thinking?: { value: string; from: "client" | "default" }; /** The sampling parameters the gateway injected on this call (the answering * slot's configured `sampling` defaults). Absent when the slot had none — * the request's own parameters then ran untouched. (Deliberately no * "client" variant: logging every passthrough call's sampling values would * be noise; the debug capture holds the exact forwarded body anyway.) */ sampling?: Record; /** Row kind. Absent on legacy lines → treated as a normal call. "cooldown" * marks a circuit-breaker event (a provider just entered cooldown), shown * distinctly in the timeline alongside the failures that caused it. */ kind?: "call" | "cooldown"; /** Present only on cooldown rows: the cooldown duration and the consecutive * failure count that triggered it. */ cooldownMs?: number; fails?: number; } /** One debug-captured upstream attempt (in-memory ring buffer ONLY — never * persisted to logs.jsonl or data.json; see ModelEntry.debugCapture). One * client call that fails over produces several entries, one per attempt, * because each attempt's forwarded body can differ (per-slot model rewrite + * thinking/sampling injection). Bodies are captured VERBATIM: `request` is the exact * JSON string sent upstream, `response` the upstream body as it flowed (raw * SSE text for streams). Headers are never captured — provider api keys stay * out of the buffer. */ export interface DebugCapture { ts: number; model: string; provider: string; providerId: string; format: RouteKey; /** The upstream model name actually sent this attempt (post per-slot * rewrite). Absent when the public name went through verbatim. */ upstreamModel?: string; /** Upstream HTTP status (0 = network error / never reached). */ status: number; /** Latency of THIS attempt (ms). */ ms: number; stream: boolean; /** The exact forwarded request body (JSON text). */ request: string; /** The upstream response body as it flowed. Absent when nothing was read * (network error) or the client cancelled before the body flowed. */ response?: string; /** True when a body was cut at the capture size cap. */ truncated?: boolean; /** Short failure reason (mirrors LogEntry.error). */ error?: string; /** The thinking level this attempt ran with (same shape as LogEntry.thinking). */ thinking?: { value: string; from: "client" | "default" }; /** The sampling parameters injected this attempt (same shape as LogEntry.sampling). */ sampling?: Record; }