How CLIProxyAPI Load-Balances Your AI Subscriptions — and What Its Scheduler Reveals About Quota Limits thumbnail
TeardownJul 1, 2026AI infrastructure

How CLIProxyAPI Load-Balances Your AI Subscriptions — and What Its Scheduler Reveals About Quota Limits

CLIProxyAPI is sold as an OpenAI-compatible proxy that round-robins across your Claude, ChatGPT/Codex, and Gemini subscriptions. Read the source and it's a reactive cooldown scheduler: it doesn't track your quota, it waits for a 429 — and only for Codex does it actually know when your limit resets.

By ArcYou Editorial. Source read against the pinned repository states listed in Methodology.

Decision brief

What this source read clarifies

What it really is
An account pooler that places multiple subscription accounts behind an OpenAI-compatible proxy
Best for
Authorized development use where applicable provider terms permit subscription pooling
Avoid if
You need exact quota prediction, team auditability, or production SLAs
Core mechanism
round-robin accounts, reactive 429 cooldown, Codex reset parsing

Read at: CLIProxyAPI-v7.2.47 @ v7.2.47

CLIProxyAPI is marketed as an OpenAI/Claude/Gemini-compatible proxy that round-robins across your subscription accounts. In the code (v7.2.47) it’s a reactive per-account cooldown state machine. It doesn’t predict your quota; it waits for a 429, then benches the account — and how long it benches depends entirely on which provider said no.

That last part is the interesting bit, so let me pull the thread all the way through. I read this at v7.2.47, commit 00114be, and every claim below points at a line.

Use and provider terms. This article explains implementation behavior; it does not recommend bypassing provider limits or service terms. Before pooling OAuth subscription accounts, confirm that the intended use is permitted by each provider’s applicable terms and use only accounts you are authorized to access.

What is it, and where does it sit?

CLIProxyAPI is a self-hosted proxy. You log into your Claude, ChatGPT/Codex, and Gemini accounts through it via OAuth, and it exposes them all as one OpenAI-compatible endpoint. Point your tools at it, and it fans requests out across whichever of your subscription logins are currently usable.

The eye-catching feature is the account scheduler — the thing that picks which of your logged-in accounts serves the next request and decides when to stop using one. That’s what I went looking at, because “round-robin load balancing across your subscriptions” is doing a lot of marketing work, and I wanted to see what it actually means in the code.

Short version: there’s no quota meter. Nothing counts your requests to keep you under a cap. The pool is gated purely by a reaction to failures — an account gets used until a provider returns 429, then it’s put in a cooldown until a timer expires.

How does the scheduler actually work?

The loop is: request → select an account → execute → MarkResult → maybe cool it down → repeat. Two halves matter — how selection picks, and how the cooldown machine benches accounts.

Selection: three strategies over the same available pool

All three selectors first ask the same question — which auths are not currently blocked — and then differ only in how they break the tie.

Strategy Tie-break rule Source
Round-robin (default) Rotates a cursor across the available auths selector.go:257-283
Fill-first Returns available[0] after an ID-sort selector.go:294-303
Session-affinity Wraps a base selector; pins a session to one auth by TTL selector.go:417-470

The word “available” is where the whole design lives. An auth is blocked for a given model when it’s marked unavailable and its retry timer is still in the future — isAuthBlockedForModel at selector.go:305-362 checks exactly Unavailable && NextRetryAfter.After(now), and it does this per-(auth, model) pair. So a Claude account benched for claude-sonnet can still serve a different model.

And what’s not there is as telling as what is. There’s a recentRequestRing that records outcomes (types.go:146-159), but it’s written in MarkResult and read only by the usage/management endpoints — it never gates selection. There’s no max_request, no message_limit, no daily_limit config anywhere. Selection is blind to how much you’ve used an account. It only knows whether that account has recently failed.

CLIProxyAPI selects from available accounts, marks 429 failures as cooldowns, and tries the next account

The scheduler does not meter hidden subscription quota. It selects an available account, observes the result, and benches failed accounts until their retry timer expires.

The cooldown machine: one central sink

Every executor outcome funnels through a single function — Manager.MarkResult at conductor.go:3496. When the status code is 429 (conductor.go:3468), it computes the next-retry time like this:

next = now + *RetryAfter   // if the executor surfaced a hint
next = now + nextQuotaCooldown(level)  // otherwise

That hint — whether one exists at all — is the crux of the whole article, and I’ll come back to it. When there’s no hint, nextQuotaCooldown (conductor.go:4153-4169) is a plain exponential backoff: 1s * 2^level, capped at 30m. It sets a Quota{Exceeded, Reason:"quota", NextRecoverAt, BackoffLevel} on the state, a success later clears it, and the whole thing is persisted to per-auth .cds files and restored (if not yet expired) at boot (conductor.go:557-611).

Status → cooldown duration, at a glance

Since MVP is text and tables (no custom diagrams), here’s the state machine as a table. Different HTTP outcomes bench an account for very different durations:

Outcome Cooldown applied Reason flag
429 with provider hint now + hint (exact reset time) Cooldown (quota)
429 without hint 1s → 2s → 4s → … → 30m exponential backoff Cooldown (quota)
401 (auth) +30m Other
402 / 403 (payment) +30m Other
404 +12h Other
400/422 model-not-supported +12h Other
5xx transient (408/500/502/503/504) +1m (configurable) Other (not quota)
Cloudflare challenge quota curve, floored at 10s Cooldown (quota)

Note the transient 5xx path sets only a retry timer, not the Quota flag (conductor.go:3629-3634) — so it reads back as a generic “other” block, not a quota cooldown. The distinction matters mostly for the management endpoints that report why an account is benched.

Design decisions: two gaps worth showing

The architecture is coherent. But two spots reveal a gap between the pitch and the mechanism, and I’d rather show you the code than editorialize.

Gap 1 — “fill-first staggers your subscription caps” is a comment, not logic

Fill-first’s comment (selector.go:32-35, from blame b078be46) says it “burns one account … to stagger rolling-window subscription caps (e.g. chat message limits).” That reads like there’s cap-aware logic underneath.

There isn’t. FillFirstSelector.Pick returns available[0] after sorting by ID (selector.go:294-303). No rolling-window math, no cap tracking. The “staggering” is an emergent effect of deterministic ordering — because the pool always drains account #1 first, the accounts hit their windows at different wall-clock times. The honest framing: fill-first hammers account #1 until it 429s, then moves to #2. That may well be the behavior you want. It’s just not enforced by cap logic; it falls out of the ordering.

Gap 2 — recovery timing is provider-specific, and in this path nobody reads Retry-After

Here’s the one I’d want to know before pooling accounts. The central machine only gets a real reset time if the executor surfaced a hint via error.RetryAfter() (conductor.go:3908-3925). So the question becomes: which executors actually populate that hint on a request 429? The answer splits sharply by provider.

Provider Where the 429 recovery time comes from Knows your real reset?
Codex / OpenAI JSON error.resets_at / error.resets_in_seconds, but only when error.type == "usage_limit_reached" (codex_executor.go:1834-1852) Yes
Claude Nothing on a request 429 → exponential backoff (claude_executor.go:324) No (guesses)
Gemini (+Vertex) Nothing → exponential backoff (gemini_executor.go:197) No (guesses)
Antigravity Google RetryInfo.retryDelay/quotaResetDelay + regex, plus a short-cooldown KV and sub-3s instant retry (json_retry_helpers.go:27-80, antigravity_executor.go:2621-2648) Yes

The money line: only Codex actually knows when your subscription resets — because ChatGPT returns resets_at in the error body, and the executor parses it. (Its per-minute rate_limit_* fields are deliberately excluded from that parse at codex_executor.go, so those still fall to backoff.) For Claude and Gemini, the proxy has no idea when your window reopens. It just backs off blindly: 1s, 2s, 4s, and so on up to 30 minutes. If you pool Claude accounts expecting the proxy to respect your real reset window, it doesn’t — it’s guessing, and it re-probes on a schedule that has nothing to do with when Anthropic actually lifts the limit.1

Is reactive-not-predictive a bad design? No. Providers don’t hand out a clean “you have N requests left” signal, so waiting for the 429 is a reasonable stance. My only nit is with the word “load balancing.” A load balancer distributes to keep everyone under capacity; this distributes and then reacts to overflow. It’s a failover cooldown scheduler wearing a load-balancer label — and for two of the three headline providers, the cooldown timing is a blind guess.

Where does this fit?

This is the axis I’d use to place CLIProxyAPI among the LLM gateways: what are you routing?

Generic gateways — LiteLLM, OpenRouter, one-api — route API keys, chosen by price, latency, or a fallback order. The unit is a billable key with a metered balance. CLIProxyAPI routes OAuth subscription accounts bound by opaque quota windows. The unit is a login you already pay a flat monthly fee for, and the constraint isn’t dollars-per-token, it’s “how many messages before the rolling window slaps you.”

That one difference reshapes everything downstream. Key routers can price-optimize because they can see the price. A subscription proxy can’t see the quota, so its only lever is reaction — which is exactly why CLIProxyAPI is a cooldown state machine and not a cost optimizer. When the llm-gateways comparison hub goes up, this is the camp CLIProxyAPI lands in: subscription-account proxies, distinct from API-key routers.

When would you reach for it?

If the applicable provider terms permit subscription pooling and you are authorized to use the accounts involved, several ChatGPT/Codex subscriptions behind one OpenAI-compatible endpoint are a technically good fit — Codex is the one provider where the scheduler knows your real reset time, so the cooldowns line up with reality. For Claude and Gemini it still works and still spreads load across accounts; just go in knowing the cooldown is a blind exponential backoff, not a reservation against your actual window.

The levers you’d actually turn (config):

  • routing.strategyround-robin (default) or fill-first; hot-reloadable (service.go:1275-1317).
  • routing.session-affinity — off by default, TTL 1h. When a client sends no session ID, it reconstructs one from a content hash (FNV-64a of system + first-user message, truncated to 100 chars) at selector.go:585-750 — worth knowing if you expect strict pinning.
  • Per-auth priority buckets — highest-priority-only selection (selector.go:115-128).
  • disable-cooling (global / per-auth / per-provider) and transient-error-cooldown-seconds (0 = 60s, <0 = off) (conductor.go:94-146).

Methodology & scope

I read CLIProxyAPI v7.2.47 at commit 00114be, focusing on the translator, auth, runtime/executor, and sdk/cliproxy/auth packages. The two headline claims — the provider-split on 429 recovery timing, and fill-first having no cap logic — were each run through an adversarial claim-verifier and confirmed. Line numbers are exact at that commit; I’ve kept code quotes to a line or two.

Scope caveats, stated plainly:

  • The xAI (grok) and Kimi executors I did not read line-by-line. They share the same statusErr type as the others, so I’d expect them to behave like the “no hint → backoff” group — but I’m scoping the provider claims to Codex, Claude, Gemini, and Antigravity, which I did read.
  • The “nobody reads Retry-After” claim is scoped to the request / quota-cooldown path, and that scoping is deliberate (see the footnote).

Footnotes

  1. One precise exception, so I don’t overstate it: Claude does read Retry-After / Retry-After-Ms — but only in the OAuth token-refresh path (parseClaudeRetryAftersetClaudeRefreshBlockedUntil, a refresh throttle) at anthropic_auth.go:89, not the request/quota-cooldown path this article is about. So it’s accurate to say in the request/quota-cooldown path, no provider reads the HTTP Retry-After header — not that Claude never reads it at all.

Methodology

This post is read against the pinned repository states below. Directory names are convenience keys; the real pin is the commit SHA.

Repo keyVersionCommitUpstream
CLIProxyAPI-v7.2.47v7.2.4700114bec1b76github.com/router-for-me/CLIProxyAPI

Related analysis

The decision axis is state ownership. Whether the product pools login sessions, meters key usage, or only carries config at the edge defines its limits.