01The idea
sub2api is a reverse proxy with a business layer on top. On one side are upstream credentials you own. On the other side are people and programs holding keys that sub2api issued. Between them sit a scheduler, a translator and a ledger.
Supply
Upstream accountsClaude Pro/Max logins, ChatGPT/Codex logins, Gemini or Antigravity Google logins, Grok, plain vendor API keys, other relays, AWS Bedrock, Vertex service accounts.
sub2api
Gateway + scheduler + ledgerChecks the caller's key, picks a healthy upstream account, rewrites the request when needed, streams the answer back and charges for the tokens.
Demand
Users and their API keysClaude Code, Codex CLI, Gemini CLI, OpenCode, any OpenAI- or Anthropic-compatible SDK. They see one base URL and one sk-… key.
Everything ships as one Go binary. It serves the Vue admin/user web app, the panel API (/api/v1/…) and the AI gateway (/v1/…, /v1beta/… and others), all from one Gin HTTP server on one port. It needs PostgreSQL for durable data and Redis for everything fast-moving: concurrency slots, sticky sessions, caches and counters.
02Five nouns
Almost every admin screen manages one of these. Once you know how they connect, the rest of the system is easy to follow.
One upstream credential. It has a platform (which provider) and a type (how it authenticates):
oauthsetup-tokenapikeyupstreambedrockservice_account
It also carries scheduling settings: concurrency (default 3), priority (default 50, lower wins), model mapping and cooldown timestamps.
A pool of accounts on one platform, or a composite group that routes each request by model name. A group also sets the rate_multiplier, the billing mode (standard balance or subscription), the model allowlist and fallbacks.
A login for the web panel. It holds a USD balance, a concurrency cap across all of that user's keys (default 5), an optional RPM limit, and access to exclusive groups.
What clients send: sk- plus 64 hex characters. Optional settings: a lifetime USD quota, expiry, an IP allow/deny list, and 5-hour, 1-day and 7-day spending windows.
An HTTP or SOCKS egress point for one or more accounts, so each subscription can call out from a stable IP of its own. A proxy can have an expiry date and fall back to a backup proxy or to a direct connection when it expires.
Platforms and the four gateway families
A platform is a provider. Each platform is handled by one of four internal gateway services, which determines the protocol the account speaks.
| Gateway family | Platforms | Native protocol |
|---|---|---|
| Anthropic | anthropic, typesafe | Claude Messages |
| OpenAI | openai, grok, kimi, zhipu, deepseek, minimax, opencode_go, command_code, cline | Responses / Chat Completions |
| Gemini | gemini | Google generateContent |
| Antigravity | antigravity | Google internal; serves Claude and Gemini models |
composite is not a provider. It is a group-only platform that looks at the model name (claude-* → anthropic, gpt-* / o3 → openai, gemini-* → gemini, and so on) or at routes you define, and sends each request to the matching concrete platform.
03Life of a request
Here is what happens when Claude Code sends POST /v1/messages with a sub2api key. Other endpoints follow the same outline.
-
Middleware: size limit, request ID, error logger
The body is capped (256 MB, 32 MB on text routes). Each request gets an
X-Client-Request-ID, which later makes billing idempotent. -
Authenticate the key api_key_auth.go
The key is read from
Authorization: Bearer, thenx-api-key, thenx-goog-api-key. sub2api loads the key with its user and group from a cache, then checks: key enabled, IP rules, user active, group enabled, and access to an exclusive group.Billing gate: subscription groups need an active subscription that is under its day/week/month limits. Balance groups need a balance above 0. In both modes the key must not be expired or over its quota.
401 missing or unknown key · 403 insufficient balance / no subscription · 429 limit exceeded · 400 key passed in
?key= -
Model allowlist and group check
If the group has a model allowlist, the requested model must be on it; otherwise the response is 404. A key with no group is rejected (403) unless the "ungrouped key scheduling" setting is on. A composite group resolves its target platform at this point.
-
Pick the handler by platform
The same URL is served by a different handler depending on the group's platform.
/v1/messageson an OpenAI-family group goes to the OpenAI gateway, which translates Anthropic to OpenAI and back (see §06). -
User concurrency slot Redis
The request takes one of the user's concurrent slots, which are counted across all of that user's keys. If none is free, it waits up to 30 s in a queue of at most 20 extra requests. Streaming clients receive SSE pings every 10 s while they wait.
429 when the queue is full or the wait times out
-
Billing re-check and in-flight reservation
Limits are re-checked: subscription windows, balance, per-user platform quotas, the key's 5h/1d/7d windows and RPM. In balance mode, sub2api reserves the estimated cost in Redis (input bytes / 4, plus max_tokens × output price), so parallel requests cannot all spend the same dollar.
-
Compute a sticky-session key
From Claude Code's session ID in
metadata.user_id, or a hash of the prompt-cache blocks, or a hash of IP + user agent + key + prompt. If Redis already binds this conversation to an account, that account is preferred, so Anthropic's prompt cache keeps hitting. -
Select an account and take its slot gateway_scheduling.go
The scheduler (see §04) picks a healthy, least-loaded account that supports the model, and takes one of that account's concurrency slots in Redis.
-
Forward upstream
The request is rebuilt for this account: model name mapping, OAuth token (refreshed if it expires within 3 min), proxy, optional TLS fingerprint, beta-header policy. If the account is a Claude subscription and the client is not Claude Code, the request is rewritten to look like Claude Code: system prompt,
metadata.user_id,X-Stainless-*headers.On a retryable failure, sub2api records a cooldown on the account, excludes it and goes back to step 8 with another account, up to 10 switches. Once any byte has reached the client, it can no longer switch.
-
Stream back
SSE events are relayed, mapped model names are changed back to what the client asked for, and token usage is collected from
message_start/message_delta. Keepalives are sent every 10 s; after 180 s with no data from upstream the request is aborted. If the client disconnects, sub2api keeps reading the upstream response so the usage can still be billed. -
Bind the session, release slots, record usage
The conversation is bound to this account for 1 h. A worker pool computes the cost and charges it in one Postgres transaction, deduplicated by
(request_id, api_key_id), then writes ausage_logsrow.
04Picking an account
This is the core of the system. For the Anthropic, Gemini and Antigravity families it works in layers; the OpenAI family has its own scoring scheduler (below).
First, which accounts are eligible at all?
Candidates come from a Redis snapshot of the group's accounts, kept current by an outbox worker and rebuilt in full every 5 minutes. An account is schedulable only if all of these hold:
status = activeand the manualschedulableswitch is on- not inside any cooldown:
rate_limit_reset_at,overload_until,temp_unschedulable_until - not expired (enforced only when
auto_pause_on_expiredis set, which is the default) - its own USD quota is not exhausted (API-key, Bedrock and service-account types)
- it supports the requested model (an empty mapping allows all models) and has no cooldown for that specific model
- Claude oauth / setup-token only: under its 5-hour cost budget, RPM and max concurrent sessions, if you configured them
Then, the layers
If the group's model_routing maps this model to specific accounts, only those are considered. This wins over sticky sessions. Use it to keep an expensive model on dedicated accounts.
If it is still eligible, it is used. If it is busy but fewer than 3 requests are waiting for it, the request waits up to 120 s rather than moving and losing the prompt cache.
Accounts at 100% load are dropped. The rest are narrowed to the lowest priority value, then the lowest load rate, then the oldest last_used_at. sub2api tries to take a slot on the winner and moves to the next candidate if that fails.
The request queues on the best candidate for up to 30 s (at most 100 waiting). After that it fails with 429 "Too many pending requests".
load_factor as the denominator instead of concurrency if you want to weight accounts differently from their hard slot limit. Slots are Redis sorted sets (concurrency:account:{id}), and stale entries expire after 30 min.
Priority gives you tiers: primary accounts at 10 and backups at 90. The backups are used only when every primary is full or cooling down. Concurrency should match what one subscription can really handle. The defaults are 3 per account and 5 per user. Note that the per-membership account_groups.priority does not affect the choice; only the account's own priority does.
The OpenAI-family scheduler
OpenAI, Grok and the Chinese providers use a different selector. It tries, in order, previous_response_id affinity, then sticky session (and leaves a sticky account that is erroring or slow to first token), then a weighted score over priority, load, queue depth, error rate and time to first token. It keeps the top 7 and picks one of them.
05When upstreams fail
An upstream error can change two things: this request may move to another account, and the account may be benched for a while. The account changes are what make the pool heal itself without manual work.
| Upstream says | This request | What happens to the account |
|---|---|---|
| 429 rate limited | switch account | Benched until the reset time from the headers. If no reset time is given, a short fallback cooldown applies (5 s by default, adjustable in settings). A Claude 5h/7d window exhaustion is recorded until that window resets. |
| 529 overloaded | switch | overload_until = now + 10 min (configurable, can be disabled) |
401 on an oauth account | switch | Token cache cleared and the account benched for 10 min, so the background refresher can repair it. With no refresh token, it goes to error. |
| 401 on any other type | switch | error: stays out until an admin clears it. This includes setup-token. |
| 403 | switch (after up to 5 quick retries on OAuth) | Platform-specific handling |
| 400 "organization disabled", "identity verification required", or Anthropic "credit balance" | error returned | error |
| Other 400 | no switch | Unchanged. Thinking-signature errors are fixed and retried automatically. Set gateway.failover_on_400 to switch on beta/tool errors. |
| 5xx / network error | switch | Logged only |
| Error after streaming started | cannot switch | The client receives an SSE error event, and the partial usage is billed |
You can change this per account. Temp-unschedulable rules match an error code plus keywords and bench the account, or just one model, for N minutes. Custom error codes (API-key accounts) make sub2api ignore every status not in your list. Pool mode is for API-key accounts that front another relay: errors are retried on the same account (3 times by default) and the account is never benched.
An account can show active in the UI and still be benched. Check, in this order: status/error_message → temp_unschedulable_until and its reason → rate_limit_reset_at / overload_until → per-model limits in extra.model_rate_limits → expiry → quota. The account menu has Clear rate limit, Clear error and Recover state actions.
Keeping OAuth tokens alive
A background job runs every 5 minutes and refreshes any OAuth token that expires within 30 minutes, with per-provider rate limits and circuit breakers. Claude oauth accounts also refresh on demand during a request, under a distributed Redis lock so that only one instance refreshes at a time. A permanent failure (invalid_grant, revoked token) sets the account to error, and you have to re-authorize it. Repeated temporary failures bench it for 10 minutes.
06Protocol translation
Clients and upstreams do not have to speak the same protocol. sub2api converts between them, using the OpenAI Responses format as the common intermediate (pkg/apicompat).
| Client calls ↓ / Group is → | Anthropic | OpenAI family | Gemini | Antigravity |
|---|---|---|---|---|
/v1/messages (Claude Code) | native | converted | converted | via /antigravity |
/v1/chat/completions | converted | native | converted | converted |
/v1/responses (Codex) | converted | native | not verified | converted |
/v1beta/models/* (Gemini CLI) | — | — | native | via /antigravity |
/v1/embeddings | 404 | openai only | 404 | 404 |
So a Claude Code user can be served by a ChatGPT/Codex account, and an OpenAI-SDK app can be served by a Claude subscription. The Use Key dialog only offers the Claude Code tab on an OpenAI group if the group has allow_messages_dispatch enabled. Images, video, TTS/STT and search endpoints exist for OpenAI and Grok groups.
07How billing works
Every request ends up as a USD amount. The upstream reports token counts, sub2api prices them, applies your markup, and deducts the result from a balance or adds it to a subscription's usage.
total_cost is the list price and actual_cost is what the user is charged; both are stored on every usage log. Multipliers must be > 0, and 1.0 means "charge the list price". The peak factor applies only to subscription groups within the configured hours.
Balance mode (standard)
- Each request deducts
actual_costfromusers.balance - Admission requires balance > 0 plus a successful in-flight reservation
- The final charge can still push the balance slightly negative; overdraft is allowed and recorded
- Optional per-user, per-platform daily/weekly/monthly caps
Subscription mode
- The user needs an active subscription to the group (assigned by an admin, a redeem code or a purchased plan)
- Spending counts against the group's daily / weekly / monthly USD limits; the balance is not touched
- Daily resets at local midnight; weekly and monthly are fixed 7- and 30-day periods counted from the subscription start
- The checks run before the request, so the last request can overshoot a limit
Where prices come from
- The group's own
model_pricingcard, if set - A pricing channel (Admin → Channels → Pricing), with overrides or per-request prices
- The LiteLLM-format catalogue, synced from
Wei-Shaw/model-price-repoon GitHub (hash checked every 10 min) and cached atdata/model_pricing.json, merged with a bundled fallback file and your optionalpricing.override_file - A hard-coded fallback table
If no price is found anywhere, the request is still served and billed $0, with a log line "Calculate cost failed" (or pricing_missing_record_zero_cost). Set billing.inflight_reservation.fail_closed_on_unpriced: true to refuse such requests instead.
Money in
Redeem codes (balance, concurrency, subscription or invitation), promo codes applied at signup, default grants for new users, built-in payments (EasyPay, Alipay, WeChat Pay, Stripe, Airwallex) with subscription plans for sale, and affiliate rebates that users transfer into their balance. For a private, single-operator setup you can ignore all of this and run in RUN_MODE=simple, which logs usage but charges nothing.
The upstream account's own "rate multiplier"
Accounts also have a rate_multiplier. It scales only what is counted against that account's quota and statistics (account_quota_cost = total_cost × account multiplier). It never changes what users pay. Use it to track, for example, that one account costs you twice as much as another.
Why cache-heavy usage logs appear after incidents
When a conversation that was bound to one account has to move to another, sub2api bills the moved request's input tokens as cache reads (ForceCacheBilling). The user is not charged as if the cache had been lost because of an upstream failure they did not cause.
08Where state lives
Knowing which store holds what tells you what is safe to wipe, what to back up, and where to make each change.
| Store | Holds | Lose it and… |
|---|---|---|
| PostgreSQL | Users, keys, groups, accounts and credentials, usage logs, orders, the settings table (about 270 toggles edited in Admin → Settings), the persisted JWT secret, audit and ops logs. The schema comes from about 300 plain-SQL migrations built into the binary and applied on every boot. | …you lose everything. Back this up. |
| Redis | Concurrency slots and wait queues, sticky sessions (1 h), the scheduler snapshot (sched:*), RPM counters, balance/subscription/auth caches with pub/sub invalidation, OAuth token cache and refresh locks, panel refresh tokens, TOTP state, leader-election locks. | …panel users are logged out and sessions lose their sticky binding; everything else rebuilds itself. |
| config.yaml + env | Infrastructure and boot-time settings: bind address, DB/Redis connection, JWT, TOTP key, gateway timeouts, pricing source, security allowlists. Env vars override keys (gateway.max_body_size → GATEWAY_MAX_BODY_SIZE). | …the server goes back into setup mode (see below). |
| Data dir | config.yaml, the .installed lock, the pricing cache, plugins, logs. | …with both config.yaml and .installed missing, the server serves the setup wizard. |
JWT secret: on first boot it is saved to the security_secrets table, and from then on the database value wins, even if you change JWT_SECRET. TOTP encryption key: it is not persisted. If it is unset, a new random key is generated at every start, any 2FA secrets become unreadable, and the admin UI refuses to enable 2FA. Always pin TOTP_ENCRYPTION_KEY.
Startup, in short
main.go checks whether setup is needed. If AUTO_SETUP=true, it installs from env vars (tests the DB and Redis, runs migrations, creates the admin, writes config.yaml and the lock). Otherwise it serves an unauthenticated setup wizard, so never expose an un-installed instance. It then loads the config, builds the object graph with Google Wire, applies migrations under a Postgres advisory lock, starts about 30 background workers (token refresher, ops metrics, cleanup, usage-record pool, backups and others) and serves HTTP. There is no write timeout, because streams can run for minutes.
09Your first hour
The shortest path from a fresh install to a working Claude Code session through your own gateway.
- Settings → General → API Base URL. Set it to the URL clients will use (for a local install,
http://127.0.0.1:8080). The "Use Key" snippets are generated from it; if it is empty they use the current browser origin. - Proxies (optional). Add one per subscription if you want each account to call out from a stable IP of its own.
- Accounts → Add. Pick the platform and type. For a Claude subscription, choose OAuth: sub2api gives you an authorize link, you log in at claude.com, and paste the code back. Prefer
oauthoversetup-token: it can be refreshed during a request, and a 401 benches it instead of putting it into error. Set its concurrency and priority. - Groups → Create. One per platform you serve. Choose
standard(balance) orsubscription, set the multiplier (1.0 = list price), and link the accounts. - Users. Your admin user already exists. Add a balance (Users → adjust) or use simple mode so that billing does not block you.
- Keys → Create a key with a group. Then use Use Key to get client config, or Import to CC Switch.
- Test. Send a request, then look at Usage (per request: tokens, list cost, charged cost, account) and Ops (errors, rejected requests, latency).
Pointing clients at it
# site root: no /v1
export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
export ANTHROPIC_AUTH_TOKEN="sk-…your sub2api key…"
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
claude
This works with an Anthropic group, or with an OpenAI-family group that has allow_messages_dispatch enabled.
# ~/.codex/config.toml
model_provider = "OpenAI"
model = "gpt-5.5"
[model_providers.OpenAI]
name = "OpenAI"
base_url = "http://127.0.0.1:8080/v1"
wire_api = "responses"
requires_openai_auth = true
# ~/.codex/auth.json
{ "OPENAI_API_KEY": "sk-…your sub2api key…" }
The Use Key dialog generates the complete file, including the model catalog URL and feature flags.
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1",
api_key="sk-…your sub2api key…")
client.chat.completions.create(
model="claude-sonnet-4-5", # served by whatever the key's group points at
messages=[{"role": "user", "content": "hi"}])
export GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:8080"
export GEMINI_API_KEY="sk-…your sub2api key…"
gemini
For Antigravity groups, append /antigravity to the base URL (for both Gemini CLI and Claude Code).
curl http://127.0.0.1:8080/v1/messages \
-H "x-api-key: sk-…" -H "content-type: application/json" \
-d '{"model":"claude-sonnet-4-5","max_tokens":64,
"messages":[{"role":"user","content":"ping"}]}'
# what this key may spend / has spent
curl http://127.0.0.1:8080/v1/sub2api/billing -H "x-api-key: sk-…"
Send the key in a header. ?key= on /v1 is rejected with 400.
10Gotchas worth knowing
Forwarded-IP trust is on by default
By default sub2api takes the client IP from X-Forwarded-For, X-Real-IP or CF-Connecting-IP as sent. Anyone who can reach the port directly can forge their IP. This only matters if you use per-key IP rules or session binding. If you put sub2api behind a reverse proxy, turn the legacy forwarded-IP switch off in Settings and list the proxy's address in server.trusted_proxies. Behind Nginx, also set underscores_in_headers on;.
Default timezone is Asia/Shanghai
Setup writes Asia/Shanghai unless TZ/TIMEZONE is set. The timezone decides when daily subscription windows reset, when cron jobs run and how reports are bucketed. (This install sets it to the host's local zone.)
Claude Code impersonation is a deliberate feature, and a risk
When a Claude subscription account serves a non-Claude-Code client, sub2api rewrites the request to look like Claude Code, keeps a per-account device fingerprint in Redis, and can imitate a Node.js TLS handshake. That is how other clients get to use a subscription, but using subscriptions this way may conflict with the provider's terms. The README opens with a notice to that effect, so read it and decide for yourself.
Upgrading is "replace binary, restart"
Migrations apply automatically on boot. Never edit an applied migration file: a checksum mismatch stops startup. Building from source needs -tags embed, or the binary serves no web UI at all (UI paths return plain 404s).
Backups shell out to pg_dump
The built-in backup feature calls pg_dump/psql and uploads to S3-compatible storage (R2 works too). Docker images bundle those tools; a bare-metal install needs them on PATH, plus a pinned TOTP key, because the S3 secret is encrypted with it.
OAuth logins are kept in one process's memory
The PKCE session between "generate auth URL" and "exchange code" lives in process memory for 30 minutes. With several instances behind a load balancer, both calls must reach the same instance.
Group model allowlists are per group, not per key
To restrict one person to certain models, give them their own group.
11Code map
Where to look when you want to see how something works.
| Question | File |
|---|---|
| Which URLs exist and which middleware they pass through | backend/internal/server/routes/gateway.go |
| Key authentication and billing gate | backend/internal/server/middleware/api_key_auth.go |
| The request loop for Claude-family requests | backend/internal/handler/gateway_handler.go, failover_loop.go |
| Account selection | backend/internal/service/gateway_scheduling.go, openai_account_scheduler.go |
| Building the upstream request, Claude Code mimicry | service/gateway_forward.go, gateway_upstream_request.go, identity_service.go |
| Upstream errors → cooldowns | service/ratelimit_service.go |
| Protocol converters | backend/internal/pkg/apicompat/ |
| Cost calculation and charging | service/gateway_usage_billing.go, billing_service.go, repository/usage_billing_repo.go |
| Concurrency slots (Lua on Redis) | repository/concurrency_cache.go |
| Platform registry | backend/internal/domain/platforms.go |
| Data model | backend/ent/schema/*.go; actual DDL in backend/migrations/*.sql |
| All config keys and defaults | deploy/config.example.yaml, internal/config/config.go |
| Setup and AUTO_SETUP | backend/internal/setup/setup.go |
| Client config snippets | frontend/src/components/keys/UseKeyModal.vue |