Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +404 to +409

Copy link
Copy Markdown
Contributor

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/combos.md` around lines 404 - 409, Update
the combo explanation documentation near the settlement statement to explicitly
describe reservation outcomes for clean completion, cancellation, transport
failure, and incomplete streams when no terminal usage event arrives.
Distinguish adapter-reported usage from estimated usage, and state whether each
case settles actual or estimated consumption, releases the reservation, or
applies another defined outcome.

Source: Path instructions


### 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the invalid cmd.exe file-input command.

Line 437 does not pass combo.json contents as the --combo-json argument. Input redirection supplies stdin, but --combo-json requires a command-line value. Remove the cmd.exe example or replace it with a command that constructs an escaped JSON argument.

As per path instructions, user-facing docs must stay in sync with actual CLI behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/combos.md` around lines 433 - 437, Remove
the invalid cmd.exe file-input example from the Windows guidance in the combo
documentation, or replace it with a command that passes the file’s JSON contents
as the --combo-json argument using syntax supported by the CLI. Keep the
PowerShell example and valid escaped JSON usage unchanged.

Source: 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.

65 changes: 65 additions & 0 deletions docs-site/src/content/docs/reference/configuration/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 unknownQuota policy.

As per path instructions, “Document incomplete or stale quota coverage explicitly.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/reference/configuration/routing.md` around lines
242 - 251, Update the economy strategy documentation near the runtime snapshot
description to state that stale or incomplete quota snapshots are treated as
quota-unknown. Clarify that these snapshots follow the configured unknownQuota
policy during selection, while preserving the existing process-local caching
behavior.

Source: 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.
45 changes: 37 additions & 8 deletions gui/src/combo-workspace-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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;
}
Expand All @@ -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}`,
};
}
Expand All @@ -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[];
Expand All @@ -79,6 +84,7 @@ export interface ComboItem {
export interface ComboSections {
failover: ComboItem[];
roundRobin: ComboItem[];
economy: ComboItem[];
}

export interface ComboAttentionItem {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep economy combos visible in the dashboard

This moves economy combos into a new sections.economy bucket, but gui/src/components/ComboWorkspace.tsx:128-131 still renders only sections.failover and sections.roundRobin. A normally configured economy combo therefore contributes to the total count but has no rail row and cannot be selected or managed, defeating the claimed GUI round-trip preservation. Render the new section or keep these items in an existing visible group.

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[] {
Expand Down Expand Up @@ -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 ?? {});
});
}

Expand All @@ -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;
Expand All @@ -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 } : {}),
Expand Down
Loading
Loading