Files
OmniRoute/docs/routing/SUBSCRIPTION_LADDER.md
Praveen K Palaniswamy b39e5ecb20 feat(routing): subscription-first auto groupings (auto/subscription, auto/thrifty) [defer to 3.8.51] (#11146)
Merged into release/v3.8.51 via batch validation: subscription-ladder + free-regime vitest suites green (32/32) on the combined tree, check:provider-consistency OK (353 canonical providers), static gates green (virtualFactory.ts frozen at merge size with dated rebaseline). Also pushed a docs commit marking rungBudgetUsd as not-yet-enforced per review, and synced the branch onto the updated release tip. Strong opt-in design failing closed where money is involved — thanks @yourspraveen!
2026-08-25 01:44:24 -03:00

10 KiB

title, version, lastUpdated
title version lastUpdated
Subscription-first routing 3.8.50 2026-08-22

Subscription-first routing

Two new auto/* ids — auto/subscription and auto/thrifty. Both are opt-in by being requested: nothing routes through them unless a caller asks for the id by name, and no existing pool, strategy, or default changes.

Why this exists

OmniRoute already answers two cost questions, and neither is the one most operators ask.

Existing mechanism Answers
hidePaidModels (autoCombo/paidModelFilter.ts) "is this model catalogued free?"
freeAccessPolicy: "strict" (strictZeroCostFilter.ts) "can this connection ever bill me?"
quotaPreflight (combo/quotaExhaustionCutoff.ts) "is this connection near its wall?"
budgetCap / budgetFallback (autoCombo/engine.ts) "cap spend, degrade to cheapest"

Every free-only mechanism fails closed — an exhausted free pool is an empty pool, never a step up to a paid option — and every paid-side mechanism is tier-agnostic. Neither answers:

"Use the quota I already pay for. When it runs out, either stop, or step up one rung at a time through the cheapest paid options — and come back the moment it resets."

Billing is a connection fact, not a model fact

classifyTier() (open-sse/services/tierResolver.ts) keys on (provider, model) and returns free | cheap | premium from catalog pricing. But whether a request costs incremental money depends on which connection serves it: the same model is plan-included through a Claude Code OAuth connection and billed per token through an API-key connection.

provider_connections.auth_type is not a safe proxy in either direction — metered OAuth connections exist, and plan-included API-key connections exist (a Copilot seat token is not a metered API key). So billing class comes from a curated catalog, open-sse/config/connectionBillingCatalog.ts, hand-set from each provider's published terms — the same pattern FreeModelBudget.hardStopGuaranteed already established for free models.

type ConnectionBillingClass = "subscription" | "metered" | "keyless" | "unknown";
type ConnectionOverageBehavior = "hard-stop" | "meters-to-paid" | "unknown";

Resolution order (autoCombo/connectionBilling.ts): the synthetic no-auth sentinel → keyless; a catalog entry matching provider and authType; a provider-wide entry; otherwise unknown. Uncurated is not freeunknown is consumed as metered everywhere, so a provider added tomorrow starts outside the subscription rung and has to be curated in deliberately.

The rung model

Five rungs in escalation order. They differ in more than price — each has its own exhaustion signal, which is why this is not merely a sort.

# Rung Membership Exhausted when
0 subscription curated billing: "subscription" quota window at/below cutoff
1 keyless the synthetic no-auth path connection cooldown / breaker
2 free metered connection, classifyTier() === "free" free allowance exhausted
3 cheap metered connection, classifyTier() === "cheap" per-rung budget consumed
4 premium metered connection, classifyTier() === "premium" per-rung budget consumed

Rungs 0-2 exhaust on quota, which is observable and already tracked. Rungs 3-4 have no quota — a paid connection serves forever — so their only sane exhaustion signal is a per-rung budget. Without one, "escalate when cheap is exhausted" has no trigger.

auto/subscription — fail closed

Pool = rung 0 only, restricted to connections whose overage is a documented hard-stop, each verified live to have quota headroom. Everything ambiguous is excluded: an uncurated provider, an unverifiable quota reading, a stale reading, or an overage that meters to paid.

An empty pool is the intended answer, not a defect — the caller's existing empty-pool path turns it into a clear error rather than a silent, billable fallback. That is the whole promise of the id.

keyless deliberately does not qualify: this grouping means "the plan I pay for", so a no-auth backend does not belong in it. Use auto/thrifty (or auto/best-free) for that.

Connection safety

A candidate is not always tied to one connection — a logical candidate carries an allowedConnectionIds allowlist, and the account actually used is chosen later, at dispatch, by open-sse/services/combo/autoStrategy.ts. Both groupings therefore verify each connection individually and rewrite allowedConnectionIds down to exactly the surviving subset — never the full original list, never one arbitrarily-chosen member. Because autoStrategy.ts already enforces that array as a hard allowlist, rewriting it here makes "verified" and "actually used" the same set by construction. This is the same invariant, and the same reasoning, as STRICT_ZERO_COST.

auto/thrifty — escalate one rung at a time

Pool = all rungs, ordered by rung index, with exhausted candidates gated out. The auto engine still scores within the surviving pool: the ladder decides which rungs are in play, scoring decides which candidate wins inside them. Ordering is stable within a rung, so the scorer's own ranking is never reshuffled by this overlay.

This is an ordering + gating overlay, not a new dispatcher: combo.ts's speculative loop already walks targets in order and falls through on failure, so a runtime exhaustion the preflight did not catch still escalates to the next rung inside the same request.

Where auto/subscription fails closed, auto/thrifty fails open: a plan-included connection with no usable quota reading is still tried first. Trying it costs nothing, and if it turns out to be exhausted the fall-through reaches the next rung anyway — whereas refusing to try it would send the request to a paid rung on missing telemetry, the exact outcome the grouping exists to avoid.

Returning to the plan after a reset

Three independent things must expire before routing returns to rung 0. Fixing only one leaves the ladder stuck on paid rungs long after the plan refilled.

  1. The quota-state cachefreeAccessQuota.ts caches per (provider, connection) with a 180s TTL. A cached entry whose own resetAt has already passed describes a window that no longer exists, so it is now treated as stale regardless of age and forces a refresh. Without this, a plan that refilled at midnight keeps reading exhausted until the TTL happens to lapse.
  2. The ladder's own state — there is none, by design. Rung eligibility is recomputed from live quota state on every pool build; no persisted "currently on rung 3" record exists that could outlive a reset and wedge routing.
  3. The connection cooldown — the exhausting 429 sets rateLimitedUntil from exponential backoff, which for a plan connection can overshoot the real reset. clampCooldownToReset() (subscriptionLadder.ts) narrows a cooldown to the upstream's own reset instant and can never extend one. It is implemented and tested but not yet wired: the quota cache is invalidated in src/sse/services/auth.ts before any cooldown is written, so resetAt must be captured earlier in that function — a change to the resilience hot path that belongs in its own reviewed PR. Until then, re-entry waits out the connection cooldown (which already prefers upstream Retry-After hints when the provider sends them).

Anti-flap

A rung that just reset is re-admitted only above reentryMinRemainingPercent (default 5), while a connection already in play only has to stay above exitCutoffPercent (default 2, matching quotaPreflight.defaultThresholdPercent). The gap is the hysteresis band — without it, a connection hovering at the cutoff oscillates between rungs on consecutive requests.

Configuration

Tuning only. There is deliberately no enabled flag: a toggle able to switch these off would leave auto/subscription quietly serving the full pool — paid models included — under a name that promises the opposite.

{
  "subscriptionLadder": {
    "exitCutoffPercent": 2,
    "reentryMinRemainingPercent": 5,
    "rungBudgetUsd": { "cheap": 5.0, "premium": 0 }, // 0 disables a rung outright
  },
}

Budget gating is inert until a spend resolver is wired: with no accounting available a paid rung is ordered but never gated. As of v3.8.51 the rungBudgetUsd setting is accepted by the schema but NOT yet enforced — treat it as reserved configuration, not an active spend cap. Rung ordering, quota-based exhaustion, and reset re-entry all work without it.

Composition

subscription and thrifty are AutoTier values, so they compose with every category: auto/coding:thrifty, auto/reasoning:subscription, and so on. The two flat ids (auto/subscription, auto/thrifty) are advertised in /v1/models and the dashboard.

Neither id is paid-tier, so isPaidTierAutoId() returns false for both and auto/subscription survives hidePaidModels.

Where the code lives

Concern File
Curated billing facts open-sse/config/connectionBillingCatalog.ts
Classifier open-sse/services/autoCombo/connectionBilling.ts
Rungs, both groupings, re-entry open-sse/services/autoCombo/subscriptionLadder.ts
Wiring into the candidate pool open-sse/services/autoCombo/virtualFactory.ts
Reset-aware cache staleness open-sse/services/autoCombo/freeAccessQuota.ts
Tier surface open-sse/services/autoCombo/suffixComposition.ts
Advertised ids open-sse/services/autoCombo/builtinCatalog.ts
Tests tests/unit/autoCombo/subscription-ladder.test.ts