Skip to content

Accounts and the Pool

In AI Switch an "account" is a route credential. Every credential belongs to exactly one platform (Codex, Claude Code, Gemini CLI, Grok, OpenCode, OpenClaw, Hermes). Once it joins that platform's pool, the local proxy can pick between credentials, rotate through them, and back off from the ones that are misbehaving.

This page covers what a credential is made of, how its status moves, the order the scheduler picks in, and what to watch out for during bulk maintenance and export.

Two kinds of credential

The database constrains the credential kind to exactly two values:

sql
kind TEXT NOT NULL CHECK (kind IN ('official','api'))
KindWhere it comes fromTypical contents
officialImported from an official sign-in state (pasted text or a file)Official OAuth credentials, subscription info, quota windows
apiHand-entered or imported third-party API credentialsBase URL + API key + upstream protocol dialect

Importing official credentials requires a batch name. The service layer first creates a batch record with source route_credential_import, then attaches the results to it; an empty batch name returns validation.batch_name_required. Batches are what let you archive or restatus "those 30 accounts I imported last Tuesday" as a group later.

Platform support varies: OpenCode, OpenClaw, and Hermes accept api credentials only, and require an explicit base URL and dialect. See Platform Support Matrix.

Fields on an API credential

Secrets and non-secrets are stored separately: the API key lands in secret_payload_json, everything else in config_json.

FieldStored inNotes
Display namedisplay_nameIdentifies the account in lists and logs; required
Base URLconfig_json.base_urlUpstream API root; required
API keysecret_payload_json.api_keyRequired; never returned in plaintext by ordinary list endpoints
Upstream dialectconfig_json.interface_formatopenai / openai-responses / anthropic / gemini
Model mappingsconfig_json.model_mappingsArray; each entry has from, to, plus optional label and supports_1m
Fetched model listconfig_json.fetched_modelsWritten by the model-list fetch, see Model Connectivity Tests
Custom-tool compatconfig_json.responses_custom_tool_compatBoolean, defaults to false
Custom User-Agentconfig_json.headers["User-Agent"]Optional; sent as a fixed request header when set
Per-turn reminderconfig_json.turn_reminderBoolean; when on, a line is appended after the newest user message on every turn. The key is omitted when off
Reminder textconfig_json.turn_reminder_textOptional; falls back to the built-in default when blank
Failure policyconfig_json.failure_policyPer-account retry and semantic-failure overrides, see Reliability and Auto Recovery
Recovery ruleconfig_json.recoveryScheduled or probe-based recovery, see Reliability and Auto Recovery
Relay balance queryconfig_json.relay_balanceWhich panel dialect to read the balance with, plus custom value paths; see below. The key is omitted when off
Last balance readingconfig_json.relay_balance_snapshotSnapshot written by the balance query

The Anthropic dialect has an auth-field choice. api_key_field accepts exactly two values: ANTHROPIC_API_KEY (the default, sends an x-api-key header) or ANTHROPIC_AUTH_TOKEN (sends Authorization: Bearer). Anything else is rejected. This exists because plenty of third-party Claude-compatible gateways accept only one of the two.

What each dialect means, and how it decides the shape a request gets rewritten into, is covered in Protocol Routing and Bridging.

How the add and edit panels are grouped

The field count outgrew a single scroll, so both the add dialog and the edit drawer group fields into tabs. Switching tabs never discards what you have typed:

TabEdit drawerAdd dialog (API account mode)
基础 ("basics")Name, email (official accounts), status, API Key, Base URL, interface format, Claude auth field, model mappingsAccount preset, name, API Key, Base URL, interface format, Claude auth field, model mappings
高级 ("advanced")Route priority, max concurrency, User-Agent, custom-tool compat, inline remote images, per-turn reminder, relay balance queryUser-Agent, custom-tool compat, relay balance query, preview JSON
故障处理 ("failure handling")Failure policy, model status, auto recoveryNone — those fields only exist after creation
其他 ("other")Secret JSON, Config JSON (official accounts), Preview JSONNone

Only the add dialog's "API account" mode is tabbed; the batch-import and external-client-import modes have few enough fields to stay as they were. When save validation fails, the panel jumps to the tab that owns the offending field instead of leaving a bottom-of-panel error with no hint where to look.

Relay station balance

A relay account (kind = "api") runs out of money. Without a balance query you only find out when a real request comes back with "insufficient quota" and the account is flipped to error. Querying up front shows you which key is nearly empty while it still has some left.

The segmented control in the 高级 ("advanced") tab picks how the balance is read:

OptionRequestExtra input
关闭 (off)none
new-apiGET <panel root>/api/usage/token/nothing
sub2apiGET <panel root>/v1/usagenothing
自定义 (custom)the URL you namerequest URL and the path to the remaining balance; the rest optional

Both built-ins reuse the Base URL and API key the account already has — no separate panel access token or user id to go and fetch.

The panel root is derived for you. An account's Base URL is usually https://panel.example.com/v1, while the panel's own API sits at the root. The query strips a trailing /v1, /v1beta or /openai/v1, then tries panel root first and the raw Base URL second, stopping at the first hit. There is no second address field to fill in.

new-api's conversion factor comes from the panel. The panel reports an integer quota that has to be divided by QuotaPerUnit to become dollars, and admins can change that number. Each query first reads quota_per_unit from the unauthenticated GET <panel root>/api/status, falling back to the shipped 500000 only when that fails. The badge tooltip records which divisor was used.

The custom option walks dotted paths; it does not execute anything.

FieldNotes
Request URLRequired, http:// or https://; authenticated with the account's own API key
Remaining pathRequired, e.g. data.total_available; numeric segments index arrays, e.g. data.plans.0.remaining
Used / total / plan-name pathsOptional
UnitOptional, defaults to USD
DivisorOptional, defaults to 1

Running it and reading it

  • The account row's balance button queries one account; "查中转站余额" in the refresh menu queries every relay account on the platform that has querying enabled.
  • The reading shows up as a badge on the account row (余额 $37.70). Hovering it reveals plan, used, total, expiry, subscription and rate-limit windows, and when it was last read. The badge turns red at zero or below, and reads "余额 不限" when the panel marks the key uncapped.
  • The edit drawer repeats the badge under the segmented control with a "立即查询" button, so a changed setting can be verified on the spot.

Nothing is polled in the background, and a low balance does not change the account's status. new-api rate-limits that endpoint to 20 requests per 20 minutes per key, and this feature aims at a whole pool of accounts rather than one active provider, so polling would walk every key into that limit. A failed query or a renamed panel field should not eject an account from scheduling either. Genuine exhaustion is still caught where it matters — on the forwarding path, see Reliability and Auto Recovery.

The status machine

Status is restricted to five values (enforced by validate_route_credential_status):

StatusMeaningScheduled?Auto-recoverable?
okHealthyYes
warningUnder observation; something went wrongNoYes
errorJudged unusableNoYes
pausedManually pausedNoYes (needs an explicit trigger)
revokedCredential is dead / has been revokedNoNo

The database default is ok. Three classes of event drive transitions:

  • Request succeeded — clears the transient failure count and backoff window; if the status was error or warning, it is pulled back to ok.
  • Request failed — routed by failure class. A permanent failure (a revoked refresh token, say) writes revoked immediately; a retryable failure increments the transient counter and sets a backoff window; a semantic failure increments a fingerprint-matched streak counter and only flips to error once the threshold is reached.
  • Explicit test succeeded — running a model connectivity test against a single account and passing triggers "explicit-test recovery", clearing every failure counter and backoff window.

revoked is the only terminal state: the reactivation SQL carries WHERE id = ? AND status != 'revoked', and the auto-recovery scheduler skips it too. Reviving a revoked account means re-importing it or changing the credential itself.

A paused account can still be tested explicitly. That is deliberate: running one test is exactly how you find out whether a paused account has come back.

Beyond account status there is a second layer of model status. Every model on an account has its own ok / error / paused state and its own cooldown window, stored in route_credential_models. An account-level paused takes the whole account out of scheduling; a model-level paused takes out only that one model. The two are independent. The edit drawer's 模型状态 ("model status") section, under the 故障处理 ("failure handling") tab, pauses, resumes, or clears the cooldown of each model individually.

Full failure classification, backoff durations, and thresholds live in Reliability and Auto Recovery.

Priority and concurrency limit

Both scheduling parameters are constrained at the database level:

sql
ALTER TABLE route_credentials
  ADD COLUMN route_priority INTEGER NOT NULL DEFAULT 3
    CHECK (route_priority BETWEEN 1 AND 5);
ALTER TABLE route_credentials
  ADD COLUMN max_concurrency INTEGER NOT NULL DEFAULT 1
    CHECK (max_concurrency >= 1);

The column default is still 1, but the account-creation write path binds 5 explicitly (DEFAULT_ROUTE_CREDENTIAL_MAX_CONCURRENCY), so new accounts effectively default to 5. Existing accounts are untouched.

ParameterRangeDefaultEffect
route_priority1–53Lower wins. Accounts sharing a value form one priority group
max_concurrency≥ 15Cap on in-flight requests for this account; when full, it is skipped this round

Scheduling order

Picking an account for one proxy request goes like this:

  1. Gather candidates. The SQL requires: in the pool and enabled (route_pool_members.enabled = 1), not archived (archived_at IS NULL), status ok, and quota columns (primary_remain, weekly_remain) either null or greater than zero. This step does not judge cooldown.
  2. Sort. ORDER BY route_priority ASC, sort_order ASC, created_at ASC — priority first, then your manual in-pool ordering, then creation time.
  3. Round-robin within groups. Candidates are grouped by route_priority and polled from a persisted cursor (the route_pool_cursors table stores next_index per platform). Persisting the cursor means a restart does not send everything at the first account again.
  4. Filter by model and resolve the model key. One pass against platform capability rules and the model named in the request, which also computes the upstream model key each survivor will be charged under (the mapping's to; official accounts keep the requested name). An empty result returns route_pool.model_unmatched.
  5. Read model state and drop what is cooling. route_credential_models is read in one batch keyed by (account id, model key), then: models that are paused or marked unhealthy are hard-excluded and cannot be reached by any fallback; anything whose account-level or model-level cooldown has not expired goes to the cooling bucket. If that empties the eligible set, the scheduler keeps only the single soonest-recovering cooling account to try, rather than failing the request outright; if not even a fallback remains (everything was hard-excluded), it returns route_pool.model_unavailable.
  6. Take a concurrency lease. try_acquire(platform, id, max_concurrency) is attempted per candidate; failing to get a lease moves on to the next account in the retry queue.

Step 4 must precede step 5. Whether something counts as cooling depends on which model was asked for, so it cannot be partitioned before the model is known. Full rules in Reliability and Auto Recovery.

The composite index behind this query is:

sql
CREATE INDEX IF NOT EXISTS idx_route_credentials_routing_priority
  ON route_credentials(platform, route_priority, status, next_retry_at, cooldown_until);

Its column order is the scheduling semantics: pools are split by platform, ordered by priority within a platform, and cooling accounts are excluded. Model-level state has an index of its own:

sql
CREATE INDEX IF NOT EXISTS idx_route_credential_models_lookup
  ON route_credential_models(route_credential_id, status, cooldown_until);

How to actually set these

  • Primary plus standby: primaries at 1 or 2, standbys at 4 or 5. Traffic only reaches the standbys once every primary is cooling or saturated.
  • Flat weighting: put everything at one priority and let round-robin spread quota consumption evenly.
  • Match concurrency to the upstream limit: if the upstream caps concurrency per key, set max_concurrency to what it allows. New accounts start at 5; drop a concurrency-sensitive upstream to 1 to go back to one in-flight request per account at any moment.

List views and batch operations

The account list pages through three mutually exclusive scopes (RouteCredentialPoolScope):

ScopeMeaning
in_poolJoined this platform's pool
out_of_poolExists but is not in the pool (the default scope)
archivedArchived

Page size accepts only 20, 50, and 100; anything else is rejected with page_size must be 20, 50, or 100.

Available single and batch operations:

OperationBehaviour
Archive / unarchiveBulk-writes or clears archived_at; archived accounts are excluded from all scheduling
Set status in bulkWrites one valid status across the selected IDs
Drag to reorderRecomputes sort_order from a "move between these two accounts" gesture, within the current filter and scope
DuplicateCopies a credential; the new name gets a YYYY-MM-DD date stamp appended
DeleteHard delete; the pool membership table has ON DELETE CASCADE, so membership rows go with it

Archive versus delete: archiving is a reversible soft-hide — the credential and its usage history survive. Deleting is not reversible. When you rotate a batch of accounts out, archive is the right tool.

Archiving has its own composite index:

sql
CREATE INDEX IF NOT EXISTS idx_route_credentials_archive
  ON route_credentials(platform, archived_at, sort_order);

Export and import

The export dialog offers two formats: a JSON file and scheme links.

Exporting means exposure

This export contains credentials. Store it securely and remove copies you no longer need.

JSON export

  • The suggested file name looks like ai-switch-<platform>-route-credentials-20260819-101530.json — the platform and a UTC timestamp are both in the name.
  • The payload carries schema_version: 1 plus metadata: source instance ID, source credential ID, platform, kind. You can turn off "enhanced metadata" to export core fields only.
  • One export covers at most 2000 credentials and 8 MiB serialized (8 * 1024 * 1024). Over the limit returns transfer.selection_too_large or transfer.export_too_large.
  • Desktop uses the system save dialog; the web service mode uses a browser download.

A scheme link is a deep link of the form aiswitch://v1/import?..., and it is generated for api credentials only (official credentials have no equivalent that fits in a URL).

Copying scheme links

Copying scheme URLs places API keys on the system clipboard.

Hitting copy raises a confirmation first, worded:

This scheme URL contains an API key. Copy it to the system clipboard?

Only after you confirm does anything reach the clipboard. Closing the export dialog immediately wipes the sensitive state the UI was holding.

Import de-duplication

The import side records each credential's origin identity so re-importing the same export does not pile up duplicates:

sql
CREATE TABLE IF NOT EXISTS route_credential_transfer_origins (
  route_credential_id TEXT PRIMARY KEY,
  source_instance_id TEXT NOT NULL,
  source_credential_id TEXT NOT NULL,
  source_platform TEXT NOT NULL,
  source_kind TEXT NOT NULL,
  source_schema_version INTEGER NOT NULL,
  source_fingerprint TEXT NOT NULL,
  imported_at TEXT NOT NULL,
  UNIQUE(source_instance_id, source_credential_id, source_platform)
);

Each installation also has its own stable identity (transfer_installation_identity), which is how "exported on machine A, imported on machine B" stays distinguishable from "imported twice on the same machine".

Beyond its own format, import also accepts export formats from other account switchers (a compatible import protocol). A schema_version mismatch returns transfer.schema_version_unsupported rather than guessing at field meanings.

Importing from another client

The add-account dialog's third tab, "导入其他客户端", reads another switcher's config file on this machine directly — no export step on that side. CC Switch is supported today:

  • Reads ~/.cc-switch/cc-switch.db (the providers table) or the older ~/.cc-switch/config.json. CC_SWITCH_HOME wins when set, and you can also pick a file by hand in the dialog.
  • The database is opened read-only and immutable, so it reads fine while the other app is running and never leaves a -wal file in someone else's directory.
  • API accounts only: official-login entries (category = official) and Claude desktop entries are listed as not importable, with the reason.
  • Each entry shows its base URL, a masked API key, the interface dialect, and the number of model mappings. You tick what you want. The plaintext key never reaches the frontend.
  • Entries belonging to another platform are counted but not listed — the accounts page is per-platform, so switch platforms to import those.

Field mapping follows each client's native config, and runs opposite to how it was written: CC Switch stores the upstream model name directly in the client's env keys, while AI Switch stores alias → upstream, so each env key is restored as that role's mapping.

SourceValueLands as
env.ANTHROPIC_AUTH_TOKEN / env.ANTHROPIC_API_KEYAPI keyapi_key, and which key it was decides api_key_field (Bearer vs x-api-key)
env.ANTHROPIC_BASE_URLBase URLbase_url
env.ANTHROPIC_DEFAULT_*_MODELPer-role upstream modelclaude-*-alias mappings; a [1M] suffix becomes supports_1m
env.CLAUDE_CODE_SUBAGENT_MODEL / env.ANTHROPIC_MODELSubagent and fallback modelsclaude-subagent / claude-model mappings
auth.OPENAI_API_KEYCodex API keyapi_key
The selected [model_providers.*].base_url in configCodex base URLbase_url
meta.apiFormat, falling back to wire_apiInterface dialectinterface_format
meta.customUserAgentCustom UAconfig_json.headers["User-Agent"]

Re-importing overwrites

The dedupe key is (external_source_client, external_source_id), where the source id is the other tool's own primary key (prefixed by app type, since CC Switch keys providers by the (id, app_type) pair):

sql
ALTER TABLE route_credentials ADD COLUMN external_source_client TEXT;
ALTER TABLE route_credentials ADD COLUMN external_source_id TEXT;

CREATE UNIQUE INDEX idx_route_credentials_external_source
  ON route_credentials(external_source_client, external_source_id)
  WHERE external_source_client IS NOT NULL AND external_source_id IS NOT NULL;

Importing the same record again overwrites the account it produced last time instead of adding a near-copy, and the preview says so up front — "覆盖已有" plus the name of the account that will be replaced. This is the deliberate difference from the credential-transfer import above, which refuses edited duplicates: here the external client is the authority for the fields it owns.

An overwrite replaces only what that client owns (name, secret, config_json, preview) and clears the failure counters; locally edited priority, concurrency ceiling, batch, and compute-pool membership all survive. revoked stays terminal — a re-import will not resurrect a revoked account. "创建后加入算力池" likewise applies only to accounts newly created by this import.

Where this is stored

All credential data lives in the app's SQLite database. The data directory is fixed at ~/.ai-switch under your home directory, and the database file is ai-switch.db (development builds use a separate ai-switch-dev.db, so the two never collide).

The data directory is a credential directory

API keys and official sign-in credentials are stored in that SQLite database (the route_credentials.secret_payload_json column), with no additional encryption at rest. Treat all of ~/.ai-switch as a credential directory:

  • Keep it out of public repositories, unencrypted sync folders, and shared drives
  • Mind the security of your backup media, and tighten the directory's file permissions
  • Prefer a machine with full-disk encryption enabled

The schema is defined by the 25 forward-only migrations under src-tauri/migrations. The ones relevant to this page:

MigrationContents
202607130011_route_credentials.sqlThe route_credentials table and the route_pool_members membership table
202607300001_route_credential_retry.sqlTransient failure count, next_retry_at, cooldown_until
202608040001_route_credential_transfer.sqlInstallation identity and import-origin table
202608050001_route_credential_archive.sqlarchived_at and the archive index
202608060002_route_usage_breakdown.sqlToken and price breakdown columns on usage_events
202608080002_route_credential_priority_concurrency.sqlroute_priority, max_concurrency, and the scheduling index
202608130001_route_credential_semantic_failure_streak.sqlSemantic-failure streak count and fingerprint
202609020001_route_credential_external_source.sqlExternal-client source columns and the dedupe unique index

Next

Released under the MIT License.