Field guide · sub2api

How sub2api works

sub2api takes AI subscriptions and API keys you already have, pools them, and resells or shares them as one API with its own users, keys, prices and limits. This guide follows a request from the client to the bill, so you know what every setting in the admin panel does.

Based on a read of the source at Wei-Shaw/sub2api (Go + Vue), October 2026Every claim was checked against the code by a second pass

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 accounts

Claude 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 + ledger

Checks 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 keys

Claude 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.

User balance · concurrency API key sk-… · quota · IP rules Group platform · price · mode Account one credential Proxy egress IP Subscription user × group, limits owns many one many↔many optional only for "subscription" groups DEMAND SIDE SUPPLY SIDE
A key belongs to one user and normally one group. The group decides which pool of accounts can serve the key, which protocol family applies, and how usage is priced. Accounts can sit in many groups.
Accountaccounts

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.

Groupgroups

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.

Userusers

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.

API keyapi_keys

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.

Proxyproxies

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 familyPlatformsNative protocol
Anthropicanthropic, typesafeClaude Messages
OpenAIopenai, grok, kimi, zhipu, deepseek, minimax, opencode_go, command_code, clineResponses / Chat Completions
GeminigeminiGoogle generateContent
AntigravityantigravityGoogle 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.

  1. 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.

  2. Authenticate the key api_key_auth.go

    The key is read from Authorization: Bearer, then x-api-key, then x-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=

  3. 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.

  4. Pick the handler by platform

    The same URL is served by a different handler depending on the group's platform. /v1/messages on an OpenAI-family group goes to the OpenAI gateway, which translates Anthropic to OpenAI and back (see §06).

  5. 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

  6. 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.

  7. 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.

  8. 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.

  9. 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.

  10. 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.

  11. 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 a usage_logs row.

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 = active and the manual schedulable switch is on
  • not inside any cooldown: rate_limit_reset_at, overload_until, temp_unschedulable_until
  • not expired (enforced only when auto_pause_on_expired is 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

Layer 1model routing
Pinned accounts

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.

Layer 1.5sticky
The account this conversation used last

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.

Layer 2load-aware
Lowest priority number → lowest load → least recently used

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.

Layer 3queue
Everyone is full: wait

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_rate = (active slots + waiting) × 100 ÷ concurrency Use the account's 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.
How to use this

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 saysThis requestWhat happens to the account
429 rate limitedswitch accountBenched 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 overloadedswitchoverload_until = now + 10 min (configurable, can be disabled)
401 on an oauth accountswitchToken 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 typeswitcherror: stays out until an admin clears it. This includes setup-token.
403switch (after up to 5 quick retries on OAuth)Platform-specific handling
400 "organization disabled", "identity verification required", or Anthropic "credit balance"error returnederror
Other 400no switchUnchanged. Thinking-signature errors are fixed and retried automatically. Set gateway.failover_on_400 to switch on beta/tool errors.
5xx / network errorswitchLogged only
Error after streaming startedcannot switchThe 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.

Debugging "no available accounts"

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 →AnthropicOpenAI familyGeminiAntigravity
/v1/messages (Claude Code)nativeconvertedconvertedvia /antigravity
/v1/chat/completionsconvertednativeconvertedconverted
/v1/responses (Codex)convertednativenot verifiedconverted
/v1beta/models/* (Gemini CLI)——nativevia /antigravity
/v1/embeddings404openai only404404

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.

actual_cost = tokens × catalogue price × group (or per-user) rate_multiplier × peak factor 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_cost from users.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

  1. The group's own model_pricing card, if set
  2. A pricing channel (Admin → Channels → Pricing), with overrides or per-request prices
  3. The LiteLLM-format catalogue, synced from Wei-Shaw/model-price-repo on GitHub (hash checked every 10 min) and cached at data/model_pricing.json, merged with a bundled fallback file and your optional pricing.override_file
  4. A hard-coded fallback table
Unpriced means free

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.

StoreHoldsLose it and…
PostgreSQLUsers, 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.
RedisConcurrency 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 + envInfrastructure 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 dirconfig.yaml, the .installed lock, the pricing cache, plugins, logs.…with both config.yaml and .installed missing, the server serves the setup wizard.
Two secrets that behave differently

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.

  1. 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.
  2. Proxies (optional). Add one per subscription if you want each account to call out from a stable IP of its own.
  3. 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 oauth over setup-token: it can be refreshed during a request, and a 401 benches it instead of putting it into error. Set its concurrency and priority.
  4. Groups → Create. One per platform you serve. Choose standard (balance) or subscription, set the multiplier (1.0 = list price), and link the accounts.
  5. Users. Your admin user already exists. Add a balance (Users → adjust) or use simple mode so that billing does not block you.
  6. Keys → Create a key with a group. Then use Use Key to get client config, or Import to CC Switch.
  7. 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.

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.

QuestionFile
Which URLs exist and which middleware they pass throughbackend/internal/server/routes/gateway.go
Key authentication and billing gatebackend/internal/server/middleware/api_key_auth.go
The request loop for Claude-family requestsbackend/internal/handler/gateway_handler.go, failover_loop.go
Account selectionbackend/internal/service/gateway_scheduling.go, openai_account_scheduler.go
Building the upstream request, Claude Code mimicryservice/gateway_forward.go, gateway_upstream_request.go, identity_service.go
Upstream errors → cooldownsservice/ratelimit_service.go
Protocol convertersbackend/internal/pkg/apicompat/
Cost calculation and chargingservice/gateway_usage_billing.go, billing_service.go, repository/usage_billing_repo.go
Concurrency slots (Lua on Redis)repository/concurrency_cache.go
Platform registrybackend/internal/domain/platforms.go
Data modelbackend/ent/schema/*.go; actual DDL in backend/migrations/*.sql
All config keys and defaultsdeploy/config.example.yaml, internal/config/config.go
Setup and AUTO_SETUPbackend/internal/setup/setup.go
Client config snippetsfrontend/src/components/keys/UseKeyModal.vue