Skip to content

feat(budget): preserve flat-rate capacity when metered budget is exhausted - #14348

Merged
diegosouzapw merged 7 commits into
diegosouzapw:release/v3.8.51from
valimwiliam2020-art:feat/flat-rate-survives-metered-budget
Sep 24, 2026
Merged

diegosouzapw merged 7 commits into
diegosouzapw:release/v3.8.51from
valimwiliam2020-art:feat/flat-rate-survives-metered-budget

Conversation

@valimwiliam2020-art

Copy link
Copy Markdown
Contributor

Problem

Dollar-budget enforcement runs in the api-key policy phase, before any provider is resolved, and the budget itself is scoped only by apiKeyId. That gate can answer "has this key spent its allowance?" but never "would this request spend any of it?".

So once the allowance is exhausted the request is rejected outright — including when the provider that would have served it is a flat-rate subscription or coding plan that the metered allowance does not pay for. A combo mixing metered and flat-rate targets cannot fall back to the flat-rate one either: nothing gets as far as candidate evaluation.

Impact

  • An exhausted metered allowance disables capacity that does not consume that allowance. Paid-for subscription capacity sits idle while the request 429s.
  • Flat-rate traffic also contributes its per-token estimate to the metered budget ledger (recordCost in chatCore), so subscription usage draws down an allowance it never spends against.

Existing capability reused

The repository already classifies provider economics explicitly, in src/lib/usage/flatRateProviders.ts (isFlatRateProvider). This patch reuses that single source of truth rather than introducing a second one, and hardcodes no provider id.

A provider that is not classified flat-rate is metered. An unknown or unclassified provider is therefore metered by construction, so it can never become a spending bypass.

Design

Budget state becomes an eligibility constraint during candidate evaluation instead of a pre-routing verdict.

request -> apiKeyId -> policy (metered budget deferred; every other check unchanged)
        -> candidates -> per candidate: health | quota | capability | MONETARY ELIGIBILITY
        -> normal ranking selects -> transport -> record the budget-consumable share
  • enforceApiKeyPolicy gains meteredBudget?: "enforce" | "defer-to-candidate". The default is "enforce", so all ~27 existing callers are behaviourally unchanged. Only the chat path opts in.
  • handleSingleModelChat re-applies the budget per resolved candidate. That is the single dispatch funnel for this endpoint — direct requests, combo targets and pipeline stages all pass through it.
  • The gate sits before credential acquisition on purpose: a refusal must not acquire an account, and a locally produced 429 must not reach the fallback loop that would read it as an upstream rate limit and cool a perfectly healthy connection.
  • The refusal is tagged BUDGET_EXCEEDED, and the combo loop now classifies that alongside TOKEN_LIMIT_EXCEEDED as a local per-API-key policy breach, so it is never mistaken for an upstream refusal.

The budget can only remove a candidate. It never nominates one, never reorders, and never selects a provider. Ranking, health, quota, capability and combo behaviour are untouched — normal routing remains authoritative over which eligible candidate is used.

below the allowance allowance exhausted
metered candidate eligible ineligible
flat-rate candidate eligible eligible
unknown candidate eligible ineligible (treated as metered)

Fail-closed behaviour

With the allowance exhausted and no eligible non-metered candidate, the request fails without any paid transport — no upstream call is made, on the direct path or through a combo. Verified by asserting that zero upstream dispatches were recorded, not just by the response status.

Accounting

OBSERVABILITY != BUDGET CONSUMPTION.

recordCost now receives the budget-consumable share of the estimate, which is zero for a flat-rate provider. Telemetry is untouched: analytics recompute cost from the request log with their own flatRateAsZero option (lib/usage/costCalculator, lib/usage/usageStats), so flat-rate traffic stays fully visible in usage and cost surfaces. No analytics behaviour was changed to make a budget test pass.

Compatibility

  • No schema migration. The budget stays one metered allowance per apiKeyId.
  • No per-provider spending policy.
  • Threshold semantics preserved: the request that crosses the limit still completes, the next one is blocked. This is not a reservation/preauthorization change.
  • Existing callers keep default enforcement.
  • No second router. The caller never selects the economic route, and never sees the economic class.

Tests

18 new tests, all passing.

  • tests/unit/metered-budget-must-not-disable-flat-rate.test.ts — 9/9. Eligibility per economic class, unknown-provider conservatism, accounting split, recovery when the allowance returns, and the local-vs-upstream classification reaching the combo exhaustion sets.
  • tests/integration/combo-matrix/metered-budget-eligibility.test.ts — 9/9. Drives the real chat pipeline and asserts which upstream was actually dispatched: metered target excluded, flat-rate target selected by normal routing, no metered transport, fail-closed with metered-only and with an unhealthy flat-rate candidate, and the provider connection left clean after a refusal (read from the connection row, because a behavioural retry can pass while a connection is quietly cooling).

Mutation proof — each control was removed in turn and the relevant tests went red; every mutation was reverted:

mutation failures
flat-rate exemption removed 8
unknown provider treated as free 1
early Tier-1 rejection restored 3
pre-credential gate removed 2
budget 429 treated as an upstream rate limit 2
flat-rate consuming the metered allowance 1

Other checks

  • typecheck:core — clean.
  • ESLint on all changed files — no new errors (pre-existing no-unused-vars counts match config/quality/eslint-suppressions.json exactly).
  • Focused cross-regression over 14 neighbouring suites (budget, combo, quota, exhaustion, responses) — 142/144.
  • tests/integration/combo-matrix/* — 34/36.

The 4 remaining failures are pre-existing. Each was confirmed by reverting this patch in the same tree and re-running: they fail identically on the unmodified base, with the same assertion signature (for example 402 !== 200 in the emergency-fallback budget cases). No assertion was rewritten.

A full-suite run is not claimed: the two attempted full sweeps were aborted by local disk exhaustion (ENOSPC) and their output is not reported as evidence.

Runtime validation scope

The behaviour was exercised through OmniRoute's real routing pipeline using the integration harness, with isolated database state and recorded upstream dispatch — not through mocked routing decisions.

It was not deployed to, or validated against, a live production OmniRoute instance. No production instance was modified or used for budget-exhaustion testing.


🤖 Generated with Claude Code

…exhausted

The dollar budget is scoped by apiKeyId and is enforced in the api-key policy
phase, which runs before any provider is resolved. That gate can answer "has
this key spent its allowance?" but never "would this request spend any of it?",
so once the allowance is gone the whole request is rejected, including when the
provider that would have served it is a flat-rate subscription the allowance
does not pay for. A mixed combo cannot fall back to that capacity either.

Make budget state a candidate constraint instead of a pre-routing verdict. The
chat path passes meteredBudget: "defer-to-candidate" to enforceApiKeyPolicy and
re-applies the budget in handleSingleModelChat, after the provider is resolved
and before a credential is acquired. That is the single dispatch funnel for the
endpoint, so direct requests, combo targets and pipeline stages are all covered.

The economic class comes from the classification the repository already has,
isFlatRateProvider, so there is one source of truth and no provider id is
hardcoded. A provider that is not classified flat-rate is metered, which keeps
an unknown provider from becoming a spending bypass.

The gate can only remove a candidate; it never nominates one. Ranking, health,
quota and compatibility are untouched and normal routing still chooses among the
eligible candidates. With the allowance spent and no eligible non-metered
candidate, the request fails closed without any paid transport.

Placing the gate before credential acquisition matters: the refusal never
acquires an account, and the locally produced 429 never reaches the fallback
loop that would read it as an upstream rate limit and cool a healthy
connection. The refusal is tagged BUDGET_EXCEEDED, and the combo loop now
classifies that alongside TOKEN_LIMIT_EXCEEDED as a local per-API-key policy
breach, so it is never mistaken for an upstream refusal.

Accounting follows the same classification. recordCost now receives the
budget-consumable share of the estimate, which is zero for a flat-rate provider,
so subscription traffic stops drawing down the metered allowance. Observability
is unaffected: analytics recompute cost from the request log with their own
flatRateAsZero option, so flat-rate traffic stays fully visible.

No schema migration. Every other caller of enforceApiKeyPolicy keeps the default
"enforce" behaviour unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@diegosouzapw

Copy link
Copy Markdown
Owner

This is a real gap and the fix is clean — reusing isFlatRateProvider as the single source
of truth instead of adding a second classification is exactly right, and the opt-in
defer-to-candidate mode means no existing caller changes behavior implicitly. Ran your test
suite locally: 9/9 pass. One blocker: the PR is currently CONFLICTING against
release/v3.8.51 — chatCore.ts, comboPredicates.ts, apiKeyPolicy.ts and 3 other files
have real line-level conflicts with recent merges (the X-CPA-TRACE-ID and
model_not_in_catalog changes). Could you rebase and resolve those? GitHub's real CI gates
won't run until that's clean, so it's hard to get further signal until then.

Wiliam Valim and others added 6 commits September 24, 2026 04:06
# Conflicts:
#	open-sse/handlers/chatCore.ts
#	src/shared/utils/apiKeyPolicy.ts
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
…le-size ceilings

Merging the current release tip left the metered-budget wiring 2 lines over
the frozen chatCore.ts ceiling and 1 line over executeTargetAttempt.ts. The
recordCost wrapper now relies on the contextual type from recordStreamingCost
and the comments are condensed; behavior is unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
The deferred metered-budget branch and the defaulted options parameter pushed
enforceApiKeyPolicy from 15 to 17 cyclomatic complexity, a new violation.
The skip-when-deferred decision moves into validateBudgetUnlessDeferred;
behavior is unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw
diegosouzapw merged commit a11eb0f into diegosouzapw:release/v3.8.51 Sep 24, 2026
10 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants