chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
---
title: "Quota Sharing Engine"
version: 3.8.40
lastUpdated: 2026-06-28
---
# Quota Sharing Engine
> **Doc reference**: `docs/routing/QUOTA_SHARE.md`
> Part of Group B (plans 16 + 22).
---
## Overview
The Quota Sharing Engine distributes a provider's time-based quota (e.g. Codex
5-hour window, Kimi 1500 req/h) fairly across multiple API keys that share the
same connection.
**Problem it solves:** OmniRoute proxies many API keys against the same upstream
provider account. Without sharing logic, a burst from key A can exhaust the
provider quota for the hour, leaving keys B and C blocked until the window resets.
The engine prevents this by:
1. Tracking each key's rolling consumption per dimension (%, requests, tokens, $).
2. Applying a work-conserving fair-share algorithm: a key may borrow from idle
shares while the global pool is not saturated.
3. Enforcing the result in the hot path (`chatCore.ts`) before the request
reaches the upstream executor.
---
## Algorithm: Fair-Share Work-Conserving
Implemented in `src/lib/quota/fairShare.ts`.
### Modes
| Condition | Mode | Behaviour |
| ------------------------------------------ | ------------ | ------------------------------------------------------ |
| `globalUsedPercent < saturationThreshold` | **Generous** | Key may borrow up to global limit minus consumed-total |
| `globalUsedPercent >= saturationThreshold` | **Strict** | Enforce individual fair share strictly |
Default `saturationThreshold = 0.5` (env `QUOTA_SATURATION_THRESHOLD`).
### Per-dimension decision
For each active dimension in the pool, the engine computes:
```
fairShareAllowed = poolLimit × (allocationWeight / 100)
consumed = current rolling value for this key (from QuotaStore.peek)
remaining = fairShareAllowed - consumed
```
Then:
- **`policy = hard`**: if `consumed > fairShareAllowed` and mode is strict → **block**.
- **`policy = soft`**: if `consumed > fairShareAllowed` and mode is strict → **penalize** (deprioritize in combo; never hard-block).
- **`policy = burst`**: allow while global headroom exists regardless of fair share.
### Cap absoluto
`capValue` + `capUnit` on an allocation is a hard ceiling independent of mode or
policy. Any dimension where `consumed >= capValue` always **blocks** the request.
### Multi-dimension check
A request is blocked if **any** dimension in the pool would block it. Dimensions
are independent — a 5h% exhaustion does not affect the weekly% dimension.
### Borrowing
In generous mode, a key whose allocation is under-consumed can use surplus from
other keys' unallocated shares. The formula is:
```
maxAllowed = globalLimit - consumedByOtherKeys
```
where `consumedByOtherKeys = consumedTotal - consumedByThisKey`. The teto global
(pool `limit` for that dimension) is always the hard ceiling.
---
## Sliding Window Counter
Implemented in `src/lib/quota/sqliteQuotaStore.ts` and `redisQuotaStore.ts`.
Two buckets per `(apiKeyId, dimensionKey)`:
- `curr`: current bucket (`floor(nowMs / windowMs)`)
- `prev`: previous bucket (`curr - 1`)
Effective rolling value:
```
effectiveBucketIndex = floor(nowMs / windowMs)
bucketStartMs = effectiveBucketIndex × windowMs
elapsed = nowMs - bucketStartMs
weight = 1 - elapsed / windowMs
effective = prev × weight + curr
```
**Precision**: ~99% accurate. The error is at most 1% of the window size at the
boundary between buckets (inherent to the 2-bucket approximation).
### Concurrency
SQLite driver: in-memory mutex per `(apiKeyId | dimensionKey)` key prevents the
read-modify-write race. Pattern mirrors `src/sse/services/auth.ts` anti-thundering-herd.
Redis driver: Lua EVAL script for atomic increment — runs as a single Redis command.
---
## Drivers
### SQLite (default, 0-install)
- Table: `quota_consumption` (see migration `073_quota_pools.sql` / `074_quota_consumption.sql`).
- Best for single-instance deployments.
- All persistence is in the existing OmniRoute SQLite DB (`DATA_DIR/storage.sqlite`).
### Redis (optional, multi-instance)
- Requires `ioredis` npm package.
- Counters stored in Redis; metadata (pools/allocations) still in SQLite.
- Best for multi-replica deployments where counters must be shared.
### Switching drivers
Via settings UI (`/dashboard/settings` → Quota Store), or via env vars:
```bash
QUOTA_STORE_DRIVER=redis
QUOTA_STORE_REDIS_URL=redis://localhost:6379
```
DB setting has precedence over env. If `driver=redis` but URL is absent or
`ioredis` is not installed, the factory falls back to SQLite and logs a warning.
Driver selection order:
1. DB setting `quotaStore.driver`
2. Env `QUOTA_STORE_DRIVER`
3. Default: `sqlite`
---
## Multi-Dimension
A pool can have multiple dimensions. Each dimension is independent:
```ts
QuotaDimension {
unit: "percent" | "requests" | "tokens" | "usd",
window: "5h" | "hourly" | "daily" | "weekly" | "monthly",
limit: number, // global pool ceiling for this dimension
}
```
**Example: Codex plan** (5h% + weekly%):
```json
[
{ "unit": "percent", "window": "5h", "limit": 100 },
{ "unit": "percent", "window": "weekly", "limit": 100 }
]
```
A request must satisfy all dimensions to be allowed.
---
## Plan Resolver
Implemented in `src/lib/quota/planResolver.ts`.
Precedence (highest to lowest):
1. **Manual DB override**`provider_plans` table, per `connectionId`.
2. **Known catalog**`src/lib/quota/planRegistry.ts` (data-only).
3. **Empty plan** — no dimensions, manual configuration required.
### Known catalog
| Provider | Dimensions |
| --------------------- | ------------------------------------------------------------- |
| `codex` | `percent/5h/100`, `percent/weekly/100` |
| `glm` | `tokens/5h` (limit=0, unknown), `tokens/weekly` |
| `minimax` | `tokens/5h`, `tokens/weekly` |
| `bailian` | `percent/5h/100`, `percent/weekly/100`, `percent/monthly/100` |
| `kimi` | `requests/hourly/1500` |
| `alibaba` | `requests/monthly/90000` |
| `openai`, `anthropic` | No default — manual configuration required |
---
## Pipeline Integration
### PRE hook (`open-sse/handlers/chatCore.ts`)
Runs before the upstream executor, after auth and policy checks:
```
resolveComboTargets / handleSingleModel
→ enforceQuotaShare(apiKeyId, connectionId, provider, estimatedCost)
→ getQuotaStore().peek() per dimension
→ fairShare.decideFairShare()
→ if block → return 429 (buildErrorBody, Hard Rule #12)
→ if allow + deprioritize → set quotaSoftPenalty=true on candidate
→ executor.execute()
```
**Fail-open**: if `enforceQuotaShare` throws, the request is allowed through
with a `pino.warn` log. This prevents a quota-engine bug from blocking all
traffic.
### POST hook (record consumption)
After a successful response:
```
executor returns success
→ spendRecorder.recordConsumption(apiKeyId, connectionId, provider, actualCost)
→ getQuotaStore().consume() per dimension
→ fail-open: errors logged as pino.warn, never propagated to client
```
**Drift note**: if `consume` fails post-response, the rolling counter under-counts.
The saturation signal from the provider (e.g. `anthropic-ratelimit-unified-5h-utilization`)
corrects the global estimate on the next request.
### Combo soft penalty (`open-sse/services/combo.ts`)
When `decision.deprioritize === true`:
```ts
if (candidate.quotaSoftPenalty) {
score *= QUOTA_SOFT_DEPRIORITIZE_FACTOR; // default 0.7
}
```
The penalty is applied after all other scoring factors. It lowers the auto-combo
probability of selecting a saturated key without hard-blocking it.
---
## UI Walkthrough
### `/dashboard/costs/quota-share` — Main pools page
Components (all in `src/app/(dashboard)/dashboard/costs/quota-share/`):
| Component | Purpose |
| ---------------------- | ----------------------------------------------------------------- |
| `QuotaConceptCard` | Introductory card explaining quota sharing to new users |
| `CreatePoolModal` | Create a new quota pool (connection + name + initial allocations) |
| `PoolCard` | Per-pool summary: name, connection, allocation count |
| `DimensionBar` | Per-dimension stacked bar: each key's share + global usage |
| `AllocationTable` | Table with consumed, fair share, deficit/surplus, borrowing flag |
| `BurnRateChart` | EMA burn-rate line chart (lazy Recharts via `dynamic()`) |
| `EditAllocationsModal` | Edit allocation weights, caps, and policies for a pool |
The page hooks:
- `usePools` — fetches `GET /api/quota/pools` every 30s.
- `usePoolUsage` — fetches `GET /api/quota/pools/[id]/usage` on demand.
- `useLocalStoragePoolMigration` — runs once on mount to migrate legacy LS data.
### `/dashboard/costs/quota-share/plans` — Provider plan config
- `ProviderPlanConfigClient.tsx`: dropdown to select a provider, view resolved
plan (auto from catalog or manual override), and edit dimensions.
- Changes write to `PUT /api/quota/plans/[connectionId]`.
- Deletion reverts to catalog or empty plan.
---
## Environment Variables
| Variable | Default | Description |
| ---------------------------------- | --------- | ------------------------------------------------------ |
| `QUOTA_STORE_DRIVER` | `sqlite` | Driver to use: `sqlite` or `redis` |
| `QUOTA_STORE_REDIS_URL` | _(empty)_ | Redis URL, e.g. `redis://localhost:6379` |
| `QUOTA_SATURATION_THRESHOLD` | `0.5` | 0..1; `>= threshold` activates strict mode |
| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | 0..1; multiplier for soft-policy combo score |
| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | Days before GC removes old `quota_consumption` buckets |
DB settings (`quotaStore.*`) override env vars.
---
## Troubleshooting
### Redis configured but not connecting
Check that `ioredis` is installed (`npm ls ioredis`) and `QUOTA_STORE_REDIS_URL`
is reachable. On connection failure the factory falls back to SQLite (logged at
`warn`).
### `peek` returns stale / fail-open
If `peek` throws, `enforceQuotaShare` treats the result as "allow" (fail-open).
Check `pino` logs for `quota:enforce` and `quota:factory` entries to identify
the root cause.
### Consumption counter drift
If the actual provider usage differs from the counters, it is expected — the
2-bucket sliding window has ~1% error at window boundaries, and `consume` is
fire-and-forget post-response. The saturation signal (`saturationSignals.ts`)
reads the real provider utilization with a 30s TTL and adjusts `globalUsedPercent`
accordingly.
### Pool shows "no data" for burn rate
`computeBurnRate` requires at least 2 historical samples. New pools without prior
`consume` calls will show `tokensPerSecond: 0` and `timeToExhaustionMs: null`.
---
## Migration from localStorage
When `/dashboard/costs/quota-share` first loads, the hook `useLocalStoragePoolMigration`
checks:
1. `localStorage.getItem("omniroute:quota-share:pools")` is non-empty.
2. `GET /api/quota/pools` returns `[]` (DB is empty).
If both are true, it posts each legacy pool to `POST /api/quota/pools` in batch,
then removes the localStorage key. The migration is idempotent: condition 2 prevents
re-migration.
---
## Internal Strategy Classification
`quota-share` is an **internal-only** routing strategy (`INTERNAL_ROUTING_STRATEGY_VALUES` in
`src/shared/constants/routingStrategies.ts`). It is used exclusively by system-minted
`qtSd/` pool combos and is deliberately excluded from `ROUTING_STRATEGY_VALUES` so it never
appears as a user-selectable option in the UI or API.
---
## Test Coverage
Two layers of automated coverage ship with the quota-share engine:
| Suite | Command | What it covers |
| :----------------- | :--------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unit (29 tests) | `node --import tsx/esm --test tests/unit/quota-share-strategy.test.ts` | DRR scheduler, saturation gating, concurrency caps, fairShare math, backlog queueing |
| Integration matrix | `npm run test:combo:matrix` | End-to-end routing decision through the real combo pipeline; DRR fairness + saturation deprioritization via live seams (`registerQuotaFetcher`, `setLKGP`, `__setHeadroomSaturationFetcherForTests`) |
The integration matrix runs in CI alongside the other 17 public strategies. The unit suite
can be run standalone.
---
## DB Schema Summary
Three tables added by migrations `073075`:
- `quota_pools` + `quota_allocations` — pool definitions and per-key allocations.
- `quota_consumption` — rolling 2-bucket counters per `(apiKeyId, dimensionKey)`.
- `provider_plans` — manual provider plan overrides (dimensions JSON per connectionId).
All tables added via idempotent `CREATE TABLE IF NOT EXISTS` migrations.