-
Notifications
You must be signed in to change notification settings - Fork 716
feat(combos): experimental economy combo routing #1498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
759a6f3
3530c6f
b53b6c2
0771bcf
f043439
3aa0352
305d6f0
21a21f3
4b6b2f3
3c69c42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -334,3 +334,156 @@ validation message. | |
|
|
||
| The error was terminal rather than target-specific. Fix invalid input, reduce an oversized context, | ||
| handle a policy refusal, or correct the rejected request origin. Combos do not hop for those cases. | ||
|
|
||
| ## Economic routing (experimental) | ||
|
|
||
| > **Experimental.** Economy combos are opt-in. Quota state is process-local (not shared across | ||
| > multiple proxy instances), snapshots are lost on restart, and without manual snapshots or a scoped | ||
| > `usage-log` feed the common case is deprioritized included targets falling through to PAYG. | ||
| > The GUI only **preserves** economy JSON fields — it is not an allowance editor. | ||
|
|
||
| Use `strategy: "economy"` when the targets in one combo are already known to be interchangeable and | ||
| should be ranked by quota opportunity cost. It is not a task classifier; keep separate combos such as | ||
| `bulk-code` and `frontier-review` for different capability classes. Existing `failover` and | ||
| `round-robin` behavior is unchanged. | ||
|
|
||
| **Ledger (Model A):** `snapshot.remaining` is a baseline from refresh/manual PUT. Reservations are | ||
| off-book concurrency holds. Settle subtracts **actual** usage only (`remaining - actual`). Cancel and | ||
| plain release drop the hold without changing remaining. | ||
|
|
||
| **Day-one reality:** without operator-supplied snapshots (or a future provider adapter), included | ||
| targets usually have unknown quota and are deprioritized (or rejected, if you set | ||
| `unknownQuota: "reject"`). Expect traffic to fall through to priced PAYG targets until you feed | ||
| snapshots. Snapshots are in-memory only and are lost on restart. | ||
|
|
||
| Static shared allowance definitions live under `economicAllowances`. Runtime remaining values are | ||
| cached snapshots, not config. A target may reference several buckets, so five-hour, weekly, monthly, | ||
| and hard-balance constraints are enforced simultaneously. Windows may be rolling, calendar-based | ||
| (with an explicit timezone), fixed expiry, or non-expiring balance. `source: "usage-log"` refreshes | ||
| from bounded local usage history **off the request path**; `manual` accepts operator-provided | ||
| snapshots. No provider quota-network call is made on the request hot path. | ||
|
|
||
| Explain payloads split **hard exclusions** (disqualify) from **soft signals** (reserve pressure, | ||
| unknown quota deprioritize, expiration pressure). Ranking ends with stable configuration order | ||
| (`configIndex`). `maxMarginalUsd` is fail-closed: unknown cash cost is excluded when the guardrail | ||
| is set. Client cancel releases reservations without burn; settlement derives credits/USD from rates | ||
| when providers only report tokens. | ||
|
|
||
| ```json | ||
| { | ||
| "economicAllowances": { | ||
| "subscription-5h": { | ||
| "unit": "credits", "capacity": 12, | ||
| "window": { "kind": "rolling", "durationMs": 18000000 }, | ||
| "rollover": false, "reserveFraction": 0.05, "source": "usage-log", | ||
| "rates": { "inputPerMillion": 0.1, "outputPerMillion": 0.6 } | ||
| }, | ||
| "subscription-month": { | ||
| "unit": "credits", "capacity": 60, | ||
| "window": { "kind": "calendar", "interval": "month", "timezone": "America/Vancouver" }, | ||
| "rollover": false, "source": "usage-log", | ||
| "rates": { "inputPerMillion": 0.1, "outputPerMillion": 0.6 } | ||
| } | ||
| }, | ||
| "combos": { | ||
| "bulk-code": { | ||
| "strategy": "economy", | ||
| "economy": { "unknownQuota": "deprioritize", "maxMarginalUsd": 0.10 }, | ||
| "targets": [ | ||
| { "provider": "included", "model": "code-fast", "allowances": ["subscription-5h", "subscription-month"] }, | ||
| { "provider": "metered", "model": "code-fast", "pricing": { "inputUsdPerMillion": 0.10, "outputUsdPerMillion": 0.60 } } | ||
| ] | ||
| }, | ||
| "frontier-review": { | ||
| "strategy": "failover", "targets": [{ "provider": "frontier", "model": "review" }] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Run `ocx combo explain bulk-code --input-tokens 2000 --output-tokens 500 --json` (or | ||
| `GET /api/combos/bulk-code/explain`) to see eligibility, soft signals, every bucket's remaining and | ||
| reserved headroom, reserve threshold, expiry pressure, stale state, marginal/cash cost, and the | ||
| selected target. Selections reserve predicted consumption locally before dispatch; races never return | ||
| an allowance-backed target without a reservation. Completion settles actual usage (including stream | ||
| EOF); cancellation releases without burn. | ||
|
|
||
| ### Configuring economy combos from the CLI | ||
|
|
||
| `ocx combo set` accepts the whole combo as JSON so economy policy, allowance references, and pricing | ||
| never need hand-editing `config.json`: | ||
|
|
||
| ```text | ||
| ocx combo set bulk-code --combo-json '{ | ||
| "strategy": "economy", | ||
| "economy": { "unknownQuota": "deprioritize", "maxMarginalUsd": 0.10 }, | ||
| "targets": [ | ||
| { "provider": "included", "model": "code-fast", "allowances": ["subscription-5h", "subscription-month"] }, | ||
| { "provider": "metered", "model": "code-fast", "pricing": { "inputUsdPerMillion": 0.10, "outputUsdPerMillion": 0.60 } } | ||
| ] | ||
| }' | ||
| ``` | ||
|
|
||
| `--targets-json` accepts the target array alone, and `--economy-json` supplies the policy alongside | ||
| the legacy `--targets` form. Malformed JSON or mixed modes exit `2` with an actionable message, and | ||
| `--json` remains output formatting only. Legacy `--targets provider/model[:weight]` and | ||
| `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, and `--display-name` continue to | ||
| work unchanged. | ||
|
|
||
| > **Windows users:** POSIX single quotes do not protect JSON in `cmd.exe` or PowerShell. On | ||
| > Windows, wrap the JSON argument in double quotes and escape inner double quotes, for example | ||
| > `ocx combo set bulk-code --combo-json "{\"strategy\":\"economy\",\"targets\":[...]}"`, or pass the | ||
| > JSON through a file with `--combo-json (Get-Content combo.json -Raw)` (PowerShell) / | ||
| > `--combo-json "<combo.json"` (`cmd.exe`). | ||
|
Comment on lines
+433
to
+437
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Remove the invalid Line 437 does not pass As per path instructions, user-facing docs must stay in sync with actual CLI behavior. 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| ### Managing runtime snapshots | ||
|
|
||
| Runtime snapshots are operator-facing state: they are never written into `config.json` and are lost | ||
| on restart. | ||
|
|
||
| ```text | ||
| GET /api/economic-allowances | ||
| GET /api/economic-allowances/<id>/snapshot | ||
| PUT /api/economic-allowances/<id>/snapshot | ||
| DELETE /api/economic-allowances/<id>/snapshot | ||
| ``` | ||
|
|
||
| `GET /api/economic-allowances` lists configured allowances with snapshot state and active | ||
| reservation counts (no secrets). | ||
|
|
||
| CLI parity: | ||
|
|
||
| ```text | ||
| ocx allowance list [--json] | ||
| ocx allowance snapshot get <id> [--json] | ||
| ocx allowance snapshot set <id> --snapshot-json '<obj>' [--clear-reservations] [--json] | ||
| ocx allowance snapshot clear <id> [--clear-reservations] [--json] | ||
| ``` | ||
|
|
||
| A `PUT` accepts the normalized snapshot shape: | ||
|
|
||
| ```json | ||
| { | ||
| "remaining": 7.5, | ||
| "updatedAt": 1754256000000, | ||
| "source": "manual", | ||
| "confidence": "authoritative", | ||
| "resetAt": 1754336000000, | ||
| "clearReservations": true | ||
| } | ||
| ``` | ||
|
|
||
| If the allowance has in-flight reservations, `PUT`/`DELETE` return **409** unless you explicitly | ||
| pass `clearReservations: true` (PUT body) or `?clearReservations=true` (DELETE). That avoids | ||
| silently stomping live accounting. | ||
|
|
||
| `remaining`, `updatedAt`, and the optional `windowStart`, `resetAt`, and `expiresAt` must be finite | ||
| non-negative safe-integer timestamps where applicable; `source` is `usage-log` | `manual` | | ||
| `codex-quota`; `confidence` is `authoritative` | `observed` | `estimated` | `unknown`. Unknown | ||
| allowance ids return `404`, malformed bodies return `400`, other methods return `405`. Responses | ||
| contain only normalized snapshot fields — never credentials or provider payloads. | ||
|
|
||
| Provider scraping, automated pricing catalogs, a full GUI allowance editor, and | ||
| native Codex quota integration are intentionally follow-up work. The GUI parser preserves economy | ||
| fields on round-trip; use `ocx allowance` or the management API for allowance snapshots today. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -236,3 +236,68 @@ The history index is disposable - deleting `routing-history.sqlite` triggers | |
| an automatic rebuild from `usage.jsonl` on the next query; `ocx logs | ||
| rebuild-index` forces one. Nothing in this system auto-tunes weights, | ||
| budgets, or candidate sets. | ||
|
|
||
| ## Economic combo routing (experimental) | ||
|
|
||
| `strategy: "economy"` is an **experimental**, additive combo strategy. Shared static allowance | ||
| buckets live under `economicAllowances` and are referenced by target `allowances` arrays; remaining | ||
| values are cached **process-local** runtime snapshots (lost on restart; not shared across instances). | ||
| Selection is deterministic: hard eligibility first, then soft reserve / unknown-quota pressure, | ||
| expiration pressure, marginal cost, and configured target order. It does not classify requests or | ||
| call provider quota APIs on the request path. Ledger: settle debits actual usage only; cancel | ||
| releases holds without burn. Optional `usageMatch.providers` / `usageMatch.models` scopes | ||
| `source: "usage-log"` refresh; unscoped usage-log summation is experimental. Use | ||
| `ocx combo explain <id> --json` and `ocx allowance …` for operator surfaces. The GUI preserves | ||
| economy fields only — no full editor. | ||
|
Comment on lines
+242
to
+251
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Document stale quota snapshots. Lines 242-251 describe missing snapshots but not stale or incomplete snapshots. A cached snapshot can still be unusable for economy selection. State that stale or incomplete snapshots are treated as quota-unknown and follow the As per path instructions, “Document incomplete or stale quota coverage explicitly.” 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| ### `economy` policy | ||
|
|
||
| ```json | ||
| "economy": { | ||
| "unknownQuota": "deprioritize", | ||
| "maxMarginalUsd": 0.10 | ||
| } | ||
| ``` | ||
|
|
||
| - `unknownQuota` (`allow` | `deprioritize` | `reject`, default `deprioritize`): how a | ||
| target behaves when a referenced allowance has no cached snapshot. | ||
| - `maxMarginalUsd` (optional, non-negative): the highest estimated per-request USD a | ||
| target may cost before it is excluded; **unknown cash cost is fail-closed excluded** | ||
| when this guardrail is set. | ||
|
|
||
| ### Target fields | ||
|
|
||
| ```json | ||
| { | ||
| "provider": "included", | ||
| "model": "code-fast", | ||
| "allowances": ["subscription-5h", "subscription-month"], | ||
| "pricing": { "inputUsdPerMillion": 0.10, "outputUsdPerMillion": 0.60 } | ||
| } | ||
| ``` | ||
|
|
||
| - `allowances`: one or more shared bucket IDs defined under `economicAllowances`. | ||
| Every referenced bucket is a binding constraint; the tightest wins. | ||
| - `pricing`: optional per-million-token USD rates for metered targets. Finite and | ||
| non-negative. | ||
|
|
||
| ### `economicAllowances` entries | ||
|
|
||
| ```json | ||
| { | ||
| "unit": "credits", | ||
| "capacity": 12, | ||
| "window": { "kind": "rolling", "durationMs": 18000000 }, | ||
| "rollover": false, | ||
| "reserveFraction": 0.05, | ||
| "source": "usage-log", | ||
| "rates": { "inputPerMillion": 0.1, "outputPerMillion": 0.6 } | ||
| } | ||
| ``` | ||
|
|
||
| - `unit`: `requests` | `inputTokens` | `outputTokens` | `totalTokens` | `credits` | `usd`. | ||
| - `window`: rolling (`durationMs`), calendar (`interval` + `timezone`), fixed-expiry | ||
| (`expiresAt`), or non-expiring balance (`kind: "balance"`). | ||
| - `source`: `usage-log` (bounded local history) | `manual` (operator snapshots) | | ||
| `codex-quota` (provider-derived, future adapters). | ||
| - `rates`: conversion to the allowance unit for request estimation. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-mo | |
|
|
||
| export { SUPPORTED_NATIVE_OPENAI_SLUGS }; | ||
|
|
||
| export type ComboStrategy = "failover" | "round-robin"; | ||
| export type ComboStrategy = "failover" | "round-robin" | "economy"; | ||
| export type ComboEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; | ||
|
|
||
| export const COMBO_EFFORTS: ComboEffort[] = ["low", "medium", "high", "xhigh", "max", "ultra"]; | ||
|
|
@@ -45,6 +45,8 @@ export interface ComboTarget { | |
| provider: string; | ||
| model: string; | ||
| weight?: number; | ||
| allowances?: string[]; | ||
| pricing?: Record<string, number>; | ||
| /** UI-only stable key for React lists; never sent to the API. */ | ||
| clientKey?: string; | ||
| } | ||
|
|
@@ -56,6 +58,8 @@ export function newComboTarget(partial: Partial<ComboTarget> = {}): ComboTarget | |
| provider: partial.provider ?? "", | ||
| model: partial.model ?? "", | ||
| ...(partial.weight !== undefined ? { weight: partial.weight } : {}), | ||
| ...(partial.allowances ? { allowances: [...partial.allowances] } : {}), | ||
| ...(partial.pricing ? { pricing: { ...partial.pricing } } : {}), | ||
| clientKey: partial.clientKey ?? `ct-${++comboTargetKeySeq}`, | ||
| }; | ||
| } | ||
|
|
@@ -71,6 +75,7 @@ export interface ComboItem { | |
| /** Display-only catalog label used by native aliases. */ | ||
| displayName: string | null; | ||
| strategy: ComboStrategy; | ||
| economy?: { unknownQuota?: "allow" | "deprioritize" | "reject"; maxMarginalUsd?: number }; | ||
| stickyLimit: number; | ||
| defaultEffort: ComboEffort | null; | ||
| targets: ComboTarget[]; | ||
|
|
@@ -79,6 +84,7 @@ export interface ComboItem { | |
| export interface ComboSections { | ||
| failover: ComboItem[]; | ||
| roundRobin: ComboItem[]; | ||
| economy: ComboItem[]; | ||
| } | ||
|
|
||
| export interface ComboAttentionItem { | ||
|
|
@@ -124,7 +130,7 @@ function normalizeAlias(raw: unknown): string | null { | |
| } | ||
|
|
||
| export function normalizeStrategy(raw: unknown): ComboStrategy { | ||
| return raw === "round-robin" ? "round-robin" : "failover"; | ||
| return raw === "round-robin" || raw === "economy" ? raw : "failover"; | ||
| } | ||
|
|
||
| export function normalizeStickyLimit(raw: unknown): number { | ||
|
|
@@ -164,7 +170,17 @@ export function parseComboList(payload: unknown): ComboItem[] { | |
| const model = typeof tr.model === "string" ? tr.model.trim() : ""; | ||
| if (!provider || !model) continue; | ||
| const weight = normalizeWeight(tr.weight); | ||
| targets.push(weight !== undefined ? newComboTarget({ provider, model, weight }) : newComboTarget({ provider, model })); | ||
| const allowances = Array.isArray(tr.allowances) ? tr.allowances.filter((value): value is string => typeof value === "string") : undefined; | ||
| const pricing = tr.pricing && typeof tr.pricing === "object" && !Array.isArray(tr.pricing) | ||
| ? Object.fromEntries(Object.entries(tr.pricing).filter(([, value]) => typeof value === "number" && Number.isFinite(value))) | ||
| : undefined; | ||
| targets.push(newComboTarget({ | ||
| provider, | ||
| model, | ||
| ...(weight !== undefined ? { weight } : {}), | ||
| ...(allowances ? { allowances } : {}), | ||
| ...(pricing ? { pricing } : {}), | ||
| })); | ||
| } | ||
| out.push({ | ||
| id, | ||
|
|
@@ -175,6 +191,7 @@ export function parseComboList(payload: unknown): ComboItem[] { | |
| nativeAlias: r.nativeAlias === true, | ||
| displayName: normalizeAlias(r.displayName), | ||
| strategy: normalizeStrategy(r.strategy), | ||
| ...(r.economy && typeof r.economy === "object" && !Array.isArray(r.economy) ? { economy: r.economy as ComboItem["economy"] } : {}), | ||
| stickyLimit: normalizeStickyLimit(r.stickyLimit), | ||
| defaultEffort: normalizeDefaultEffort(r.defaultEffort), | ||
| targets, | ||
|
|
@@ -186,11 +203,13 @@ export function parseComboList(payload: unknown): ComboItem[] { | |
| export function groupCombos(items: ComboItem[]): ComboSections { | ||
| const failover: ComboItem[] = []; | ||
| const roundRobin: ComboItem[] = []; | ||
| const economy: ComboItem[] = []; | ||
| for (const item of items) { | ||
| if (item.strategy === "round-robin") roundRobin.push(item); | ||
| else if (item.strategy === "economy") economy.push(item); | ||
| else failover.push(item); | ||
|
Comment on lines
208
to
210
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This moves economy combos into a new AGENTS.md reference: gui/AGENTS.md:L9-L10 Useful? React with 👍 / 👎. |
||
| } | ||
| return { failover, roundRobin }; | ||
| return { failover, roundRobin, economy }; | ||
| } | ||
|
|
||
| export function filterCombos(items: ComboItem[], query: string): ComboItem[] { | ||
|
|
@@ -234,13 +253,16 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean { | |
| || a.nativeAlias !== b.nativeAlias | ||
| || a.displayName !== b.displayName | ||
| || a.strategy !== b.strategy | ||
| || JSON.stringify(a.economy) !== JSON.stringify(b.economy) | ||
| || a.stickyLimit !== b.stickyLimit | ||
| || a.defaultEffort !== b.defaultEffort | ||
| ) return false; | ||
| if (a.targets.length !== b.targets.length) return false; | ||
| return a.targets.every((t, i) => { | ||
| const o = b.targets[i]!; | ||
| return t.provider === o.provider && t.model === o.model && (t.weight ?? 1) === (o.weight ?? 1); | ||
| return t.provider === o.provider && t.model === o.model && (t.weight ?? 1) === (o.weight ?? 1) | ||
| && JSON.stringify(t.allowances ?? []) === JSON.stringify(o.allowances ?? []) | ||
| && JSON.stringify(t.pricing ?? {}) === JSON.stringify(o.pricing ?? {}); | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -252,6 +274,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} | |
| strategy: ComboStrategy; | ||
| stickyLimit?: number; | ||
| defaultEffort: ComboEffort | null; | ||
| economy?: ComboItem["economy"]; | ||
| alias?: string; | ||
| nativeAlias?: true; | ||
| displayName?: string; | ||
|
|
@@ -261,11 +284,17 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} | |
| id: item.id.trim(), | ||
| ...(options.renameFrom ? { renameFrom: options.renameFrom } : {}), | ||
| combo: { | ||
| targets: item.targets.map((target) => item.strategy === "round-robin" | ||
| ? { provider: target.provider.trim(), model: target.model.trim(), weight: target.weight ?? 1 } | ||
| : { provider: target.provider.trim(), model: target.model.trim() }), | ||
| targets: item.targets.map((target) => ({ | ||
| provider: target.provider.trim(), | ||
| model: target.model.trim(), | ||
| ...(item.strategy === "round-robin" ? { weight: target.weight ?? 1 } : {}), | ||
| ...(target.allowances ? { allowances: [...target.allowances] } : {}), | ||
| ...(target.pricing ? { pricing: { ...target.pricing } } : {}), | ||
| ...(item.strategy === "economy" && target.weight !== undefined ? { weight: target.weight } : {}), | ||
| })), | ||
| strategy: item.strategy, | ||
| defaultEffort: item.defaultEffort, | ||
| ...(item.economy ? { economy: { ...item.economy } } : {}), | ||
| ...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}), | ||
| ...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}), | ||
| ...(item.nativeAlias ? { nativeAlias: true } : {}), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Document incomplete-stream settlement behavior.
Line 408 states that completion settles actual usage, but it does not describe transport failures or incomplete streams. Operators cannot determine whether the reservation is released or partially settled when a terminal usage event is absent. State the behavior for clean completion, cancellation, transport failure, and incomplete streams. Distinguish adapter-reported usage from estimated usage.
As per path instructions, “Streaming usage may arrive in terminal events, so reservation settlement must account for clean completion versus cancellation, transport failure, and incomplete streams.”
🤖 Prompt for AI Agents
Source: Path instructions