auto-model-router 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +29 -4
- package/omp-extension/report-logic.ts +93 -0
- package/omp-extension/router-configure.ts +128 -2
- package/omp-extension/router-embed.ts +9 -6
- package/package.json +1 -1
- package/src/catalog/ollama-catalog.ts +30 -2
- package/src/cli/config-wizard.ts +11 -0
- package/src/cli/report.ts +7 -2
- package/src/config/defaults.ts +17 -0
- package/src/config/schema.ts +8 -0
- package/src/config/types.ts +65 -0
- package/src/cost/feedback.ts +81 -0
- package/src/cost/ledger.ts +110 -5
- package/src/cost/report.ts +118 -2
- package/src/cost/types.ts +32 -1
- package/src/router/candidates.ts +13 -4
- package/src/router/classify.ts +13 -0
- package/src/router/features.ts +59 -1
- package/src/router/index.ts +23 -5
- package/src/router/learned.ts +202 -0
- package/src/router/select.ts +64 -8
- package/src/router/types.ts +39 -1
- package/src/server/http.ts +82 -5
- package/src/server/overrides.ts +83 -0
- package/src/server/providers.ts +10 -2
- package/src/server/turn.ts +16 -1
- package/src/upstream/ollama-usage.ts +79 -2
- package/src/util/sqlite.ts +30 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +2 -0
- package/test/classify.test.ts +13 -0
- package/test/config-wizard.test.ts +8 -6
- package/test/controls.test.ts +238 -0
- package/test/escalate.test.ts +1 -0
- package/test/failover.test.ts +6 -4
- package/test/features.test.ts +63 -0
- package/test/http-resilience.test.ts +1 -1
- package/test/learned.test.ts +61 -0
- package/test/ollama.test.ts +74 -2
- package/test/report-hub.test.ts +6 -2
- package/test/report-logic.test.ts +3 -0
- package/test/report.test.ts +57 -0
- package/test/select.test.ts +126 -1
- package/test/trust-attribution.test.ts +95 -0
- package/test/turn.test.ts +36 -4
- package/tools/replay.ts +267 -156
- package/tools/train-classifier.ts +111 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.4.1",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.4.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -499,11 +499,13 @@ What it shows, for the window:
|
|
|
499
499
|
|
|
500
500
|
| Block | Columns |
|
|
501
501
|
| --- | --- |
|
|
502
|
-
| totals | spend, dispatches, conversations, $/dispatch, prompt and completion tokens, cache hit rate, model switches, escalations, failovers, errors (aborted separately) |
|
|
502
|
+
| totals | spend, dispatches, conversations, $/dispatch, prompt and completion tokens, cache hit rate, model switches, escalations, failovers, errors (aborted separately), subagent dispatches and their share of spend |
|
|
503
|
+
| prompt anatomy | mean share of prompt bytes by role (tool results, assistant, user, system), tool schemas beside them, the older half of the conversation, and tool results older than the newest 20 messages — what compaction can reach. Recorded per turn from v0.3.5. |
|
|
503
504
|
| providers | per upstream (`openrouter`, `ollama`): dispatches, spend, share, cache hit, mean TTFT, tokens/s, escalations, errors |
|
|
504
|
-
| models | per served slug (top 12 by spend): the same plus the tier mix it was routed for |
|
|
505
|
+
| models | per served slug (top 12 by spend): the same plus user feedback (`+good/-bad` from `/router good\|bad`) and the tier mix it was routed for |
|
|
505
506
|
| tiers | per tier: dispatches, spend, share, cache hit, mean prompt tokens, escalations |
|
|
506
507
|
| by day | UTC calendar days: dispatches, spend, cache hit |
|
|
508
|
+
| same traffic on one model | the window's tokens priced on each `report.baselines` model at list price with the window's cache hit rate, and what share the router saved against it |
|
|
507
509
|
|
|
508
510
|
Spend follows the ledger's rule — the provider's reported cost when it gave
|
|
509
511
|
one, else the usage-priced figure the router computed, else the forecast.
|
|
@@ -530,6 +532,10 @@ Status); the subcommands go straight there:
|
|
|
530
532
|
| `/router config` | Section picker over **every** config key: Server, OpenRouter, Ollama Cloud, Benchmarks, Tiers, Tasks, Filters, Classifier, Escalation, Hysteresis, Exploration, Cache, Compaction, Context (agentdox), Budget, Ledger, Logging, Profiles. Only `ollama.prices` and `ollama.twins` (maps) stay YAML-only. |
|
|
531
533
|
| `/router report` | Usage analytics in a fullscreen hub styled like `/models`: pick a view in the sidebar, set the window (24h / 7d / 30d / 90d) and the harness scope there too. `/router report 30d --all` presets them. See [Usage reports](#usage-reports). |
|
|
532
534
|
| `/router status` | The router's `/health`: key sources, catalog size and age, Ollama availability, plan usage and cost bias, agentdox bridge. |
|
|
535
|
+
| `/router why` | Explain this session's last routed turn: model and provider, tier, classification source and confidence, cost, cache hit, latency, the full decision trail and classifier reasons, any feedback already given. |
|
|
536
|
+
| `/router good` / `/router bad [note]` | Judge that turn. Recorded against the model that served it (`POST /v1/router/feedback`), shown per model in the report's `feedback` column, and the label the de-escalation work needs. `/router feedback good\|bad` is the same. |
|
|
537
|
+
| `/router pin <model\|off>` | Route this session to one model until cleared (admitted past price, quality and trust filters; tool support and context window still apply). Escalations and failovers after the first attempt still run. |
|
|
538
|
+
| `/router tier <tier\|off> [turns]` | Force a tier for N committed turns (default 10; 0 = until cleared). Shown with no argument. Overrides are per omp session, live in the router process only, and lapse after 12 idle hours. |
|
|
533
539
|
|
|
534
540
|
Picking a section lists its fields with their current values (pending edits
|
|
535
541
|
marked), so you see the settings before choosing one to change. Each field
|
|
@@ -586,6 +592,7 @@ what each one does. All values are optional; omit a key to use its default.
|
|
|
586
592
|
| `host` | `127.0.0.1` | Bind address. `0.0.0.0`/`::` listen on all interfaces (the provider still advertises loopback). |
|
|
587
593
|
| `port` | `0` | Bind port. `0` = let the OS pick a free ephemeral port (the embedded router's default). |
|
|
588
594
|
| `apiKey` | unset | Optional client bearer token. When set, every request must send `Authorization: Bearer <key>`. |
|
|
595
|
+
| `subagentProfile` | `auto-sub` | Profile omp subagents are routed under when they ask for the default one. The embed extension marks sessions without a UI with `X-Omp-Subagent: 1`; delegated work (reads, searches, summaries) never needs the top tier. Empty disables the remap. |
|
|
589
596
|
| `harnessId` | unset | Harness identity sent as `X-Omp-Harness`; scopes per-harness daily budgets and toasts. |
|
|
590
597
|
|
|
591
598
|
### `openrouter`
|
|
@@ -657,12 +664,15 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
|
|
|
657
664
|
| `deny` | `[]` | Glob denylist; matching slugs are excluded. |
|
|
658
665
|
| `includeFree` | `false` | Include free models (rate-limited hard; usually excluded). |
|
|
659
666
|
| `requireToolSupport` | `true` | Only models that support tool calls. |
|
|
667
|
+
| `feedbackWeight` | `0` | How much a `/router good\|bad` verdict weighs in a model's trust rate: a bad verdict counts as this many failures, a good one as this many successes. `0` records verdicts without acting on them. |
|
|
660
668
|
| `minTrust` | `0.7` | Minimum success rate; models below this (after `minTrustSamples`) are demoted. |
|
|
661
669
|
| `minTrustSamples` | `12` | Attempts before trust is enforced. |
|
|
662
670
|
| `trustScopedByHarness` | `false` | `true` = each harness reads only its own trust rows. |
|
|
663
671
|
| `contextHeadroom` | `1.25` | Fraction of context kept free (a model must fit prompt × this). |
|
|
664
672
|
| `latencyWeight` | `0` | How hard to penalise slow models in scoring (soft multiplier on effective cost). `0` disables it. |
|
|
665
673
|
| `latencyMinSamples` | `20` | Streamed samples before latency is judged against a model. |
|
|
674
|
+
| `cacheReliabilityMinSamples` | `10` | Warm-expected samples before a model's observed cache hit rate discounts its "stay warm" price in the stay/switch comparison. A model whose cache misses when it should be warm (measured: 5-6% on glm/gemini, 11% on ling, 50% on nex) is kept less eagerly. `0` assumes every cache is reliable. |
|
|
675
|
+
| `latencyWeightContinuation` | unset | Latency weight on tool-result continuations (the agent loop's own follow-ups). Unset ⇒ `latencyWeight` everywhere; lower it to spend speed only where a person waits on first token. |
|
|
666
676
|
| `maxExpectedWaitMs` | unset | Absolute expected-wait ceiling (ms): a hard drop for models *proven* slower (≥ `latencyMinSamples`), regardless of price. The soft penalty is multiplicative and capped, so it cannot demote a slow-but-cheap model — this can. New models keep their cold-start turns; relaxed with trust in tier rescue. Undefined ⇒ off. |
|
|
667
677
|
| `escalationCostWeight` | `0` | Price a model's measured escalation rate at what an escalated retry actually bills (the ledger's $/prompt-token of `attempt > 0` rows), 0–1. The trust divisor reads a 4% escalation rate as a 4% surcharge; the real cost is a whole re-dispatch on the next tier's model. `0` disables the term. |
|
|
668
678
|
|
|
@@ -671,6 +681,7 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
|
|
|
671
681
|
| Key | Default | Meaning |
|
|
672
682
|
| --- | --- | --- |
|
|
673
683
|
| `ambiguityThreshold` | `0.6` | Below this heuristic confidence, the adjudicator model decides the tier. |
|
|
684
|
+
| `learnedModelPath` | unset | A model written by `bun tools/train-classifier.ts` (logistic regression over the ledger's recorded features, label = the turn escalated). When set, every decision records `learned: p(escalate)=…`. Advisory only: it never moves a tier until replay shows it should. |
|
|
674
685
|
| `model` | `qwen/qwen3.7-flash` | Adjudicator model slug. |
|
|
675
686
|
| `maxCostFraction` | `0.02` | Adjudicator cost cap as a fraction of the turn's budget. |
|
|
676
687
|
| `maxCostUsd` | `0.002` | Absolute adjudicator cost cap, USD. |
|
|
@@ -679,6 +690,7 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
|
|
|
679
690
|
| `toolAxis` | `coding` | Quality axis for tool-heavy turns. |
|
|
680
691
|
| `chatAxis` | `intelligence` | Quality axis for chat turns. |
|
|
681
692
|
| `agenticLoopDepth` | `3` | Tool-loop depth at which a turn is treated as agentic. |
|
|
693
|
+
| `readOnlyToolWeight` | `0` | Score subtracted when a tool-result continuation follows an assistant turn that used only read-only tools (read, grep, glob, ls, lsp…). Recorded as `features.readOnlyToolTail` either way; enable after `tools/replay.ts` prices it. |
|
|
682
694
|
| `mechanicalRetryFactor` | `0.2` | Fraction of the failed-tool and circular-call weights kept on a tool-result continuation; `1` disables the damping. |
|
|
683
695
|
|
|
684
696
|
### `escalation` — mid-stream retry upward
|
|
@@ -749,6 +761,7 @@ shrunk results stay shrunk (rewriting them would break the prompt cache).
|
|
|
749
761
|
| `perTurnUsd` | unset | Per-turn cap (checked against the cold forecast). |
|
|
750
762
|
| `perConversationUsd` | unset | Per-conversation cap. |
|
|
751
763
|
| `perDayUsd` | unset | Rolling 24h cap, scoped per harness when `harnessId` is set. |
|
|
764
|
+
| `perMonthUsd` | unset | Calendar-month (UTC) target. Paced: the daily cap becomes min(`perDayUsd`, remaining ÷ days left), so a month running ahead tightens automatically. The breach reason names the pace. |
|
|
752
765
|
| `onExceeded` | `downgrade` | `downgrade` = pick the cheapest viable model; `reject` = fail the turn. |
|
|
753
766
|
|
|
754
767
|
### `profiles` — the virtual models omp sees
|
|
@@ -757,13 +770,19 @@ Each profile is a complete entry (arrays replace wholesale):
|
|
|
757
770
|
|
|
758
771
|
| Key | Default | Meaning |
|
|
759
772
|
| --- | --- | --- |
|
|
760
|
-
| `id` | `auto` / `auto-cheap` / `auto-max` | Model id omp selects. |
|
|
773
|
+
| `id` | `auto` / `auto-cheap` / `auto-max` / `auto-sub` | Model id omp selects. `auto-sub` (trivial..moderate) is what subagents get via `server.subagentProfile`. |
|
|
761
774
|
| `name` | `Auto (auto-model-router)` etc. | Display name. |
|
|
762
775
|
| `minTier` / `maxTier` | `trivial`/`hard`, `trivial`/`simple`, `moderate`/`hard` | Tier envelope. |
|
|
763
776
|
| `contextWindow` | `400000` | Advertised context window (drives omp's compaction). |
|
|
764
777
|
| `maxTokens` | `32000` | Advertised max output tokens. |
|
|
765
778
|
| `budget` | unset | Per-profile budget overrides. |
|
|
766
779
|
|
|
780
|
+
### `report` — usage-report options
|
|
781
|
+
|
|
782
|
+
| Key | Default | Meaning |
|
|
783
|
+
| --- | --- | --- |
|
|
784
|
+
| `baselines` | `anthropic/claude-opus-5`, `anthropic/claude-sonnet-5` | Models the report prices the window's traffic on as a single-model counterfactual. Unknown slugs are skipped. |
|
|
785
|
+
|
|
767
786
|
### `ledger` — cost measurement
|
|
768
787
|
|
|
769
788
|
| Key | Default | Meaning |
|
|
@@ -827,7 +846,13 @@ What happens once it is on:
|
|
|
827
846
|
flags these rows (`usage.cachedEstimated`) and reports show their cache
|
|
828
847
|
rate as `~N%`. `/router status` shows ollama.com's own dollar reading as
|
|
829
848
|
the cross-check: the plan is read from `POST /api/me` and its published
|
|
830
|
-
allowance applied (`planCreditsUsd` overrides it).
|
|
849
|
+
allowance applied (`planCreditsUsd` overrides it). **The estimate is
|
|
850
|
+
calibrated against the meter:** every usage poll records the meter beside
|
|
851
|
+
the ledger's Ollama total, and once the span carries ~$0.50 of metered
|
|
852
|
+
spend the ratio (clamped to 0.5–2×) scales every new Ollama cost the
|
|
853
|
+
router records, so the ledger tracks the bill rather than the list price.
|
|
854
|
+
Status shows the factor and, at the last week's burn, how many days of
|
|
855
|
+
credits remain.
|
|
831
856
|
- **Same economics, same failover.** Candidates from both providers are ranked
|
|
832
857
|
together; `costBias` tilts the comparison while a plan's included credits
|
|
833
858
|
would otherwise go unused. **Credit-aware by default:** the router reads the
|
|
@@ -76,6 +76,8 @@ export interface HealthSnapshot {
|
|
|
76
76
|
lastTrip?: { kind?: string; atMs?: number; message?: string } | null;
|
|
77
77
|
usage?: { monthlyUsedFraction?: number | null; activityCostUsd?: number | null; plan?: string | null; fetchedAtMs?: number | null } | null;
|
|
78
78
|
meter?: { usedUsd?: number; creditsUsd?: number; plan?: string | null } | null;
|
|
79
|
+
calibration?: { factor?: number; meterDeltaUsd?: number; ledgerDeltaUsd?: number; spanHours?: number } | null;
|
|
80
|
+
runway?: { dailyBurnUsd?: number; creditsLeftUsd?: number; days?: number | null } | null;
|
|
79
81
|
costBias?: { configured?: number; effective?: number; biasUntilUsage?: number };
|
|
80
82
|
} | null;
|
|
81
83
|
catalog?: {
|
|
@@ -111,8 +113,99 @@ export function renderStatus(baseUrl: string, h: HealthSnapshot, nowMs = Date.no
|
|
|
111
113
|
const bias = o.costBias === undefined ? "" : ` · cost bias ×${o.costBias.effective ?? o.costBias.configured ?? 1} (until ${((o.costBias.biasUntilUsage ?? 1) * 100).toFixed(0)}%)`;
|
|
112
114
|
const trip = o.lastTrip !== undefined && o.lastTrip !== null ? ` · last trip ${o.lastTrip.kind ?? "?"}${o.lastTrip.atMs ? ` ${mins(nowMs - o.lastTrip.atMs)} ago` : ""}` : "";
|
|
113
115
|
out.push(`ollama cloud: ${o.models ?? 0} models · ${avail} · key ${o.apiKeySource ?? "?"} · ${usage}${bias}${trip}`);
|
|
116
|
+
const c = o.calibration;
|
|
117
|
+
const rw = o.runway;
|
|
118
|
+
const calText = c !== undefined && c !== null && c.factor !== undefined ? `ledger estimate ×${c.factor.toFixed(2)} to match the meter (${(c.spanHours ?? 0).toFixed(0)}h span)` : "ledger estimate uncalibrated (needs ~$0.50 of metered spend)";
|
|
119
|
+
const rwText = rw !== undefined && rw !== null ? ` · burn $${(rw.dailyBurnUsd ?? 0).toFixed(2)}/day · ${rw.days === null || rw.days === undefined ? "credits left: unknown burn" : `~${Math.round(rw.days)} days of credits left`}` : "";
|
|
120
|
+
if (o.meter !== undefined && o.meter !== null) out.push(`ollama billing: ${calText}${rwText}`);
|
|
114
121
|
}
|
|
115
122
|
const a = h.agentdox;
|
|
116
123
|
out.push(a === undefined || a === null ? "agentdox: off" : `agentdox: ${a.url ?? "?"} scope ${a.defaultScope ?? "?"}${a.recordTurns === true ? " · recording turns" : ""}`);
|
|
117
124
|
return out.join("\n");
|
|
118
125
|
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// /router why, feedback, pin, tier
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/** The decision fields `/router why` renders, as `/v1/router/decisions` returns them. */
|
|
132
|
+
export interface WhyEntry {
|
|
133
|
+
id: string;
|
|
134
|
+
createdAtMs: number;
|
|
135
|
+
turn: number;
|
|
136
|
+
slug: string;
|
|
137
|
+
servedSlug: string | null;
|
|
138
|
+
tier: string;
|
|
139
|
+
classificationSource: string;
|
|
140
|
+
confidence: number | null;
|
|
141
|
+
task: string | null;
|
|
142
|
+
reasons: string[];
|
|
143
|
+
classifierReasons: string[] | null;
|
|
144
|
+
reportedUsd: number | null;
|
|
145
|
+
predictedUsd: number;
|
|
146
|
+
usage: { promptTokens: number; cachedTokens: number; completionTokens: number; cachedEstimated?: boolean };
|
|
147
|
+
latencyMs: number;
|
|
148
|
+
ttftMs: number | null;
|
|
149
|
+
attempt: number;
|
|
150
|
+
escalationSignal: string | null;
|
|
151
|
+
feedback?: Array<{ verdict: string; note: string; createdAtMs: number }>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const money = (v: number): string => (v >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(4)}`);
|
|
155
|
+
|
|
156
|
+
/** Plain-text explanation of one routed turn. */
|
|
157
|
+
export function renderWhy(e: WhyEntry, nowMs = Date.now()): string {
|
|
158
|
+
const served = e.servedSlug ?? e.slug;
|
|
159
|
+
const provider = served.startsWith("ollama/") ? "ollama" : "openrouter";
|
|
160
|
+
const cache = e.usage.promptTokens > 0 ? `${((100 * e.usage.cachedTokens) / e.usage.promptTokens).toFixed(0)}%${e.usage.cachedEstimated === true ? " est." : ""}` : "–";
|
|
161
|
+
const age = Math.max(0, nowMs - e.createdAtMs);
|
|
162
|
+
const ago = age < 60_000 ? `${Math.round(age / 1000)}s ago` : age < 3_600_000 ? `${Math.round(age / 60_000)}m ago` : `${(age / 3_600_000).toFixed(1)}h ago`;
|
|
163
|
+
const out: string[] = [];
|
|
164
|
+
out.push(`turn ${e.turn} · ${ago} · ${provider} · ${served}${e.servedSlug !== null && e.servedSlug !== e.slug ? ` (asked ${e.slug})` : ""} [${e.tier}${e.attempt > 0 ? `, attempt ${e.attempt + 1}` : ""}]`);
|
|
165
|
+
out.push(
|
|
166
|
+
`classified ${e.tier} by ${e.classificationSource}${e.confidence !== null ? ` at ${(e.confidence * 100).toFixed(0)}% confidence` : ""}${e.task !== null ? ` · task ${e.task}` : ""}`,
|
|
167
|
+
);
|
|
168
|
+
out.push(
|
|
169
|
+
`cost ${money(e.reportedUsd ?? e.predictedUsd)}${e.reportedUsd === null ? " (forecast)" : ""} · prompt ${e.usage.promptTokens.toLocaleString("en-US")} tok (cache ${cache}) · completion ${e.usage.completionTokens.toLocaleString("en-US")} tok · ttft ${e.ttftMs === null ? "–" : `${(e.ttftMs / 1000).toFixed(1)}s`} · total ${(e.latencyMs / 1000).toFixed(1)}s`,
|
|
170
|
+
);
|
|
171
|
+
if (e.escalationSignal !== null) out.push(`escalation signal: ${e.escalationSignal}`);
|
|
172
|
+
out.push("", "decision trail:");
|
|
173
|
+
for (const r of e.reasons) out.push(` - ${r}`);
|
|
174
|
+
if (e.classifierReasons !== null && e.classifierReasons.length > 0) {
|
|
175
|
+
out.push("", "classifier:");
|
|
176
|
+
for (const r of e.classifierReasons) out.push(` - ${r}`);
|
|
177
|
+
}
|
|
178
|
+
if (e.feedback !== undefined && e.feedback.length > 0) {
|
|
179
|
+
out.push("", "feedback:");
|
|
180
|
+
for (const f of e.feedback) out.push(` - ${f.verdict}${f.note !== "" ? `: ${f.note}` : ""}`);
|
|
181
|
+
}
|
|
182
|
+
out.push("", `judge it: /router good or /router bad [note]`);
|
|
183
|
+
return out.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type OverrideRequest = { kind: "pin"; slug: string | null } | { kind: "tier"; tier: string | null; turns: number } | { kind: "show" } | { kind: "clear" } | { kind: "error"; message: string };
|
|
187
|
+
|
|
188
|
+
const TIERS = ["trivial", "simple", "moderate", "hard"];
|
|
189
|
+
|
|
190
|
+
/** Parses `/router pin <slug|off>` and `/router tier <tier|off> [turns]`. */
|
|
191
|
+
export function parseOverrideArgs(verb: "pin" | "tier", text: string): OverrideRequest {
|
|
192
|
+
const parts = text.trim().split(/\s+/).filter((t) => t !== "");
|
|
193
|
+
const first = parts[0] ?? "";
|
|
194
|
+
if (first === "") return { kind: "show" };
|
|
195
|
+
if (["off", "clear", "none"].includes(first.toLowerCase())) return verb === "pin" ? { kind: "pin", slug: null } : { kind: "tier", tier: null, turns: 0 };
|
|
196
|
+
if (verb === "pin") return { kind: "pin", slug: first };
|
|
197
|
+
const tier = first.toLowerCase();
|
|
198
|
+
if (!TIERS.includes(tier)) return { kind: "error", message: `unknown tier "${first}" (${TIERS.join(" | ")} | off)` };
|
|
199
|
+
const turns = parts[1] === undefined ? 10 : Number.parseInt(parts[1], 10);
|
|
200
|
+
if (!Number.isInteger(turns) || turns < 0) return { kind: "error", message: `turns must be a whole number (0 = until cleared), got "${parts[1]}"` };
|
|
201
|
+
return { kind: "tier", tier, turns };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One-line description of a live override, for notifications. */
|
|
205
|
+
export function describeOverride(o: { slug: string | null; tier: string | null; turnsLeft: number } | null): string {
|
|
206
|
+
if (o === null || (o.slug === null && o.tier === null)) return "no override on this session";
|
|
207
|
+
const parts: string[] = [];
|
|
208
|
+
if (o.slug !== null) parts.push(`pinned to ${o.slug}`);
|
|
209
|
+
if (o.tier !== null) parts.push(`tier forced to ${o.tier}`);
|
|
210
|
+
return `${parts.join(", ")} · ${o.turnsLeft === 0 ? "until cleared" : `${o.turnsLeft} turn${o.turnsLeft === 1 ? "" : "s"} left`}`;
|
|
211
|
+
}
|
|
@@ -12,6 +12,15 @@
|
|
|
12
12
|
* them. Headless sessions get the text instead.
|
|
13
13
|
* /router status the router's /health: keys, catalog, Ollama
|
|
14
14
|
* availability and plan usage, agentdox.
|
|
15
|
+
* /router why explain this session's last routed turn: model,
|
|
16
|
+
* tier, confidence, cost, cache, the decision trail.
|
|
17
|
+
* /router good | bad [note]
|
|
18
|
+
* judge that turn; recorded against the model that
|
|
19
|
+
* served it (/router feedback good|bad is the same).
|
|
20
|
+
* /router pin <model|off> route this session to one model until cleared.
|
|
21
|
+
* /router tier <tier|off> [turns]
|
|
22
|
+
* force a tier for N turns (default 10; 0 = until
|
|
23
|
+
* cleared). Escalations and failovers still apply.
|
|
15
24
|
*
|
|
16
25
|
* Configuration walks the same sections and fields as `auto-model-router
|
|
17
26
|
* config` (reusing `WIZARD_SECTIONS` / `PROFILE_FIELDS` from the router's
|
|
@@ -49,7 +58,17 @@ import { matchesKey, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
|
49
58
|
|
|
50
59
|
import { editProfile, editSectionMenu, type ConfigUi, type SelectOption } from "./configure-logic.ts";
|
|
51
60
|
import { ReportHub } from "./report-hub.ts";
|
|
52
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
describeOverride,
|
|
63
|
+
fetchReport,
|
|
64
|
+
parseOverrideArgs,
|
|
65
|
+
parseReportArgs,
|
|
66
|
+
renderStatus,
|
|
67
|
+
renderWhy,
|
|
68
|
+
type HealthSnapshot,
|
|
69
|
+
type ReportRequest,
|
|
70
|
+
type WhyEntry,
|
|
71
|
+
} from "./report-logic.ts";
|
|
53
72
|
import { routerAuthHeaders, routerBaseUrl } from "./router-url.ts";
|
|
54
73
|
|
|
55
74
|
// This harness's id, matching the X-Omp-Harness header the router records.
|
|
@@ -76,10 +95,28 @@ export default function (pi: ExtensionAPI): void {
|
|
|
76
95
|
case "status":
|
|
77
96
|
case "health":
|
|
78
97
|
return status(pi, ctx);
|
|
98
|
+
case "why":
|
|
99
|
+
case "explain":
|
|
100
|
+
return why(pi, ctx);
|
|
101
|
+
case "good":
|
|
102
|
+
case "bad":
|
|
103
|
+
return feedback(ctx, verb.toLowerCase() as "good" | "bad", tail);
|
|
104
|
+
case "feedback": {
|
|
105
|
+
const [v = "", ...note] = rest;
|
|
106
|
+
if (v !== "good" && v !== "bad") {
|
|
107
|
+
ctx.ui.notify("usage: /router feedback good|bad [note]", "warn");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
return feedback(ctx, v, note.join(" "));
|
|
111
|
+
}
|
|
112
|
+
case "pin":
|
|
113
|
+
return override(ctx, "pin", tail);
|
|
114
|
+
case "tier":
|
|
115
|
+
return override(ctx, "tier", tail);
|
|
79
116
|
case "":
|
|
80
117
|
break;
|
|
81
118
|
default:
|
|
82
|
-
ctx.ui.notify(`unknown /router subcommand "${verb}" (config | report
|
|
119
|
+
ctx.ui.notify(`unknown /router subcommand "${verb}" (config | report | status | why | good | bad | pin | tier)`, "warn");
|
|
83
120
|
return;
|
|
84
121
|
}
|
|
85
122
|
|
|
@@ -87,11 +124,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
87
124
|
{ label: "Configure", description: "edit any router setting" },
|
|
88
125
|
{ label: "Report", description: "usage analytics; window and scope adjustable inside" },
|
|
89
126
|
{ label: "Status", description: "keys, catalog, Ollama, agentdox" },
|
|
127
|
+
{ label: "Why", description: "explain this session's last routed turn" },
|
|
128
|
+
{ label: "Override", description: "pin a model or force a tier for this session" },
|
|
90
129
|
]);
|
|
91
130
|
if (chosen === undefined) return;
|
|
92
131
|
if (chosen === "Configure") return configure(ctx);
|
|
93
132
|
if (chosen === "Status") return status(pi, ctx);
|
|
94
133
|
if (chosen === "Report") return report(pi, ctx, "");
|
|
134
|
+
if (chosen === "Why") return why(pi, ctx);
|
|
135
|
+
if (chosen === "Override") return override(ctx, "tier", "");
|
|
95
136
|
},
|
|
96
137
|
});
|
|
97
138
|
}
|
|
@@ -173,6 +214,91 @@ async function report(pi: ExtensionAPI, ctx: ExtensionContext, argText: string):
|
|
|
173
214
|
post(pi, renderUsageReport(data));
|
|
174
215
|
}
|
|
175
216
|
|
|
217
|
+
/** POST JSON to the router; throws on a non-2xx with the router's message. */
|
|
218
|
+
async function routerPost<T>(path: string, body: unknown): Promise<T> {
|
|
219
|
+
const res = await fetch(`${routerBaseUrl()}${path}`, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { ...routerAuthHeaders(), "content-type": "application/json" },
|
|
222
|
+
body: JSON.stringify(body),
|
|
223
|
+
signal: AbortSignal.timeout(5_000),
|
|
224
|
+
});
|
|
225
|
+
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
|
226
|
+
if (!res.ok) {
|
|
227
|
+
const err = json?.error as { message?: string } | undefined;
|
|
228
|
+
throw new Error(err?.message ?? `router returned ${res.status}`);
|
|
229
|
+
}
|
|
230
|
+
return json as T;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function why(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
234
|
+
const session = ctx.sessionManager.getSessionId();
|
|
235
|
+
try {
|
|
236
|
+
const res = await fetch(`${routerBaseUrl()}/v1/router/decisions?limit=1&session=${encodeURIComponent(session)}`, {
|
|
237
|
+
headers: routerAuthHeaders(),
|
|
238
|
+
signal: AbortSignal.timeout(5_000),
|
|
239
|
+
});
|
|
240
|
+
if (!res.ok) throw new Error(`router returned ${res.status}`);
|
|
241
|
+
const body = (await res.json()) as { entries: WhyEntry[] };
|
|
242
|
+
const entry = body.entries[0];
|
|
243
|
+
if (entry === undefined) {
|
|
244
|
+
ctx.ui.notify("no routed turn in this session yet", "info");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
post(pi, renderWhy(entry));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
ctx.ui.notify(`router unreachable: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function feedback(ctx: ExtensionContext, verdict: "good" | "bad", note: string): Promise<void> {
|
|
254
|
+
try {
|
|
255
|
+
const r = await routerPost<{ slug: string; tier: string }>("/v1/router/feedback", { ompSessionId: ctx.sessionManager.getSessionId(), verdict, note });
|
|
256
|
+
ctx.ui.notify(`recorded ${verdict} for ${r.slug} [${r.tier}]${note !== "" ? `: ${note}` : ""}`, "info");
|
|
257
|
+
} catch (err) {
|
|
258
|
+
ctx.ui.notify(`feedback not recorded: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function override(ctx: ExtensionContext, verb: "pin" | "tier", text: string): Promise<void> {
|
|
263
|
+
const session = ctx.sessionManager.getSessionId();
|
|
264
|
+
let req = parseOverrideArgs(verb, text);
|
|
265
|
+
if (req.kind === "show" && verb === "tier" && text === "") {
|
|
266
|
+
// Menu path: pick a tier interactively.
|
|
267
|
+
const chosen = await ctx.ui.select("Force a tier for this session", [
|
|
268
|
+
{ label: "trivial", description: "cheapest models" },
|
|
269
|
+
{ label: "simple", description: "" },
|
|
270
|
+
{ label: "moderate", description: "" },
|
|
271
|
+
{ label: "hard", description: "strongest models" },
|
|
272
|
+
{ label: "off", description: "clear any override" },
|
|
273
|
+
]);
|
|
274
|
+
if (chosen === undefined) return;
|
|
275
|
+
req = parseOverrideArgs("tier", chosen);
|
|
276
|
+
}
|
|
277
|
+
if (req.kind === "error") {
|
|
278
|
+
ctx.ui.notify(req.message, "warn");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
if (req.kind === "show") {
|
|
283
|
+
const res = await fetch(`${routerBaseUrl()}/v1/router/override?session=${encodeURIComponent(session)}`, { headers: routerAuthHeaders(), signal: AbortSignal.timeout(5_000) });
|
|
284
|
+
const body = (await res.json()) as { override: { slug: string | null; tier: string | null; turnsLeft: number } | null };
|
|
285
|
+
ctx.ui.notify(describeOverride(body.override), "info");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const payload: Record<string, unknown> = { ompSessionId: session };
|
|
289
|
+
if (req.kind === "clear") payload.clear = true;
|
|
290
|
+
else if (req.kind === "pin") payload.slug = req.slug;
|
|
291
|
+
else {
|
|
292
|
+
payload.tier = req.tier;
|
|
293
|
+
payload.turns = req.turns;
|
|
294
|
+
}
|
|
295
|
+
const r = await routerPost<{ override: { slug: string | null; tier: string | null; turnsLeft: number } | null }>("/v1/router/override", payload);
|
|
296
|
+
ctx.ui.notify(describeOverride(r.override), "info");
|
|
297
|
+
} catch (err) {
|
|
298
|
+
ctx.ui.notify(`override not applied: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
176
302
|
async function status(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
177
303
|
try {
|
|
178
304
|
post(pi, await loadStatus());
|
|
@@ -89,7 +89,7 @@ function trackProcessExit(): void {
|
|
|
89
89
|
* Registers the auto-model-router provider (and its virtual models) into omp's model
|
|
90
90
|
* registry at a specific bound port.
|
|
91
91
|
*/
|
|
92
|
-
function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig, sessionId: string): void {
|
|
92
|
+
function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig, sessionId: string, subagent: boolean): void {
|
|
93
93
|
// cwd is omp's workspace, which is what the agentdox scope is derived from
|
|
94
94
|
// when none is configured explicitly.
|
|
95
95
|
const providerConfig = buildProviderConfig(port, cfg, process.cwd());
|
|
@@ -100,6 +100,9 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
|
|
|
100
100
|
// Per-session scoping: lets the toast surface only this session's decisions
|
|
101
101
|
// even when several omp sessions share one embedded router's ledger.
|
|
102
102
|
if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
|
|
103
|
+
// A session without a UI is a subagent (or a headless run): the router
|
|
104
|
+
// routes its turns under server.subagentProfile.
|
|
105
|
+
if (subagent) headers["X-Omp-Subagent"] = "1";
|
|
103
106
|
// Which agentdox project's shared context this workspace's turns draw on.
|
|
104
107
|
if (providerConfig.agentdoxScope !== undefined && providerConfig.agentdoxScope !== "") {
|
|
105
108
|
headers["X-Agentdox-Scope"] = providerConfig.agentdoxScope;
|
|
@@ -171,7 +174,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
171
174
|
// second bind would take a different port and orphan every model handle
|
|
172
175
|
// omp already resolved against the first one.
|
|
173
176
|
if (app !== null && boundPort !== null) {
|
|
174
|
-
registerRouterProvider(pi, boundPort, cfg, sessionId);
|
|
177
|
+
registerRouterProvider(pi, boundPort, cfg, sessionId, !ctx.hasUI);
|
|
175
178
|
return;
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -181,7 +184,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
181
184
|
// The main writes the port file before spawning subagents.
|
|
182
185
|
const shared = readEmbedPort(portFile);
|
|
183
186
|
if (shared !== null && (await probeEmbed(shared))) {
|
|
184
|
-
registerRouterProvider(pi, shared, cfg, sessionId);
|
|
187
|
+
registerRouterProvider(pi, shared, cfg, sessionId, !ctx.hasUI);
|
|
185
188
|
return;
|
|
186
189
|
}
|
|
187
190
|
// No live interactive session (headless batch runs, CI, the
|
|
@@ -193,7 +196,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
193
196
|
if (started.server.port === undefined) return;
|
|
194
197
|
app = started;
|
|
195
198
|
boundPort = started.server.port;
|
|
196
|
-
registerRouterProvider(pi, boundPort, cfg, sessionId);
|
|
199
|
+
registerRouterProvider(pi, boundPort, cfg, sessionId, !ctx.hasUI);
|
|
197
200
|
return;
|
|
198
201
|
}
|
|
199
202
|
|
|
@@ -207,7 +210,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
207
210
|
// default — never take this path, so sessions stay independent.
|
|
208
211
|
if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
|
|
209
212
|
writeEmbedPort(portFile, requestedPort);
|
|
210
|
-
registerRouterProvider(pi, requestedPort, cfg, sessionId);
|
|
213
|
+
registerRouterProvider(pi, requestedPort, cfg, sessionId, !ctx.hasUI);
|
|
211
214
|
pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
|
|
212
215
|
return;
|
|
213
216
|
}
|
|
@@ -266,7 +269,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
266
269
|
|
|
267
270
|
// Register BEFORE any await: everything omp resolves after this point
|
|
268
271
|
// picks up the live URL, so the registration must not sit behind I/O.
|
|
269
|
-
registerRouterProvider(pi, actualPort, cfg, sessionId);
|
|
272
|
+
registerRouterProvider(pi, actualPort, cfg, sessionId, !ctx.hasUI);
|
|
270
273
|
pi.setLabel(`auto-model-router embed :${actualPort}`);
|
|
271
274
|
|
|
272
275
|
// NO `session_shutdown` teardown. That event is emitted from session
|
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* price and twin; the dispatch client strips the `ollama/` prefix.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import type { Database } from "bun:sqlite";
|
|
25
26
|
import type { OllamaConfig } from "../config/types.ts";
|
|
26
27
|
import type { Logger } from "../util/log.ts";
|
|
27
28
|
import { normalizeModelKey } from "./benchmark-feeds.ts";
|
|
@@ -202,13 +203,35 @@ export function ollamaApiRoot(baseUrl: string): string {
|
|
|
202
203
|
return baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
|
|
206
|
+
/** The persisted Ollama model set, or [] when none has been built yet. */
|
|
207
|
+
export function loadOllamaCatalogCache(db: Database): { models: CatalogModel[]; fetchedAtMs: number } {
|
|
208
|
+
const row = db.query("SELECT payload, fetched_at_ms FROM ollama_catalog_cache WHERE id = 1").get() as
|
|
209
|
+
| { payload: string; fetched_at_ms: number }
|
|
210
|
+
| null;
|
|
211
|
+
if (row === null) return { models: [], fetchedAtMs: 0 };
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(row.payload) as unknown;
|
|
214
|
+
return { models: Array.isArray(parsed) ? (parsed as CatalogModel[]) : [], fetchedAtMs: row.fetched_at_ms };
|
|
215
|
+
} catch {
|
|
216
|
+
return { models: [], fetchedAtMs: 0 };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: FetchLike = fetch, db?: Database): OllamaCatalogSource {
|
|
206
221
|
const root = ollamaApiRoot(cfg.baseUrl);
|
|
207
222
|
const direct = isOllamaDotCom(cfg.baseUrl);
|
|
208
223
|
const headers: Record<string, string> = {};
|
|
209
224
|
if (cfg.apiKey !== "") headers.authorization = `Bearer ${cfg.apiKey}`;
|
|
210
|
-
|
|
225
|
+
// Hydrate from disk so a restart peeks a real set before the first listing;
|
|
226
|
+
// listedAtMs stays 0 so the first get() still refreshes.
|
|
227
|
+
let models: CatalogModel[] = db === undefined ? [] : loadOllamaCatalogCache(db).models;
|
|
211
228
|
let listedAtMs = 0;
|
|
229
|
+
const persist = db === undefined
|
|
230
|
+
? null
|
|
231
|
+
: db.query(
|
|
232
|
+
`INSERT INTO ollama_catalog_cache (id, payload, fetched_at_ms) VALUES (1, ?, ?)
|
|
233
|
+
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, fetched_at_ms = excluded.fetched_at_ms`,
|
|
234
|
+
);
|
|
212
235
|
let inflight: Promise<CatalogModel[]> | null = null;
|
|
213
236
|
// `/api/show` results are stable per id; fetched once per process.
|
|
214
237
|
const shown = new Map<string, { contextLength: number | null; capabilities: string[] }>();
|
|
@@ -257,6 +280,11 @@ export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: F
|
|
|
257
280
|
log.warn("ollama listing yielded no priced cloud models; keeping the previous set", { listed: listings.length });
|
|
258
281
|
} else {
|
|
259
282
|
models = built;
|
|
283
|
+
try {
|
|
284
|
+
persist?.run(JSON.stringify(models), Date.now());
|
|
285
|
+
} catch (err) {
|
|
286
|
+
log.debug("ollama catalog persist failed", { error: err instanceof Error ? err.message : String(err) });
|
|
287
|
+
}
|
|
260
288
|
}
|
|
261
289
|
listedAtMs = Date.now();
|
|
262
290
|
} catch (err) {
|
package/src/cli/config-wizard.ts
CHANGED
|
@@ -114,6 +114,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
114
114
|
{ path: "server.port", label: "Listen port", kind: "number", min: 1, max: 65535 },
|
|
115
115
|
{ path: "server.apiKey", label: "Client bearer token", kind: "string", optional: true, secret: true },
|
|
116
116
|
{ path: "server.harnessId", label: "Default harness id", kind: "string", optional: true },
|
|
117
|
+
{ path: "server.subagentProfile", label: "Subagent profile", kind: "string", optional: true, hint: "profile id for omp subagents; blank = none" },
|
|
117
118
|
{ path: "server.maxConcurrentTurns", label: "Max concurrent turns", kind: "number", min: 1, hint: "per process, all sessions" },
|
|
118
119
|
],
|
|
119
120
|
},
|
|
@@ -182,6 +183,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
182
183
|
{ path: "filters.includeFree", label: "Include free models", kind: "boolean" },
|
|
183
184
|
{ path: "filters.requireToolSupport", label: "Require tool support", kind: "boolean" },
|
|
184
185
|
{ path: "filters.minTrust", label: "Min trust", kind: "number", min: 0, max: 1 },
|
|
186
|
+
{ path: "filters.feedbackWeight", label: "Feedback weight in trust", kind: "number", min: 0, hint: "0=record only; a bad verdict = this many failures" },
|
|
185
187
|
{ path: "filters.minTrustSamples", label: "Min trust samples", kind: "number", min: 0 },
|
|
186
188
|
{ path: "filters.trustScopedByHarness", label: "Scope trust per harness", kind: "boolean" },
|
|
187
189
|
{ path: "filters.trustWindowDays", label: "Trust window", kind: "number", min: 0, hint: "days, 0=all time" },
|
|
@@ -190,7 +192,9 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
190
192
|
{ path: "filters.latencyReferenceMs", label: "Latency reference TTFT", kind: "number", min: 1, hint: "ms" },
|
|
191
193
|
{ path: "filters.latencyReferenceTokensPerSec", label: "Latency reference speed", kind: "number", min: 1, hint: "tok/s" },
|
|
192
194
|
{ path: "filters.latencyMinSamples", label: "Latency min samples", kind: "number", min: 0 },
|
|
195
|
+
{ path: "filters.cacheReliabilityMinSamples", label: "Cache reliability min samples", kind: "number", min: 0, hint: "0=assume caches reliable" },
|
|
193
196
|
{ path: "filters.maxExpectedWaitMs", label: "Max expected wait", kind: "number", min: 1, optional: true, hint: "ms, hard ceiling" },
|
|
197
|
+
{ path: "filters.latencyWeightContinuation", label: "Latency weight on tool continuations", kind: "number", min: 0, optional: true, hint: "unset = latencyWeight" },
|
|
194
198
|
{ path: "filters.escalationCostWeight", label: "Escalation cost weight", kind: "number", min: 0, max: 1 },
|
|
195
199
|
],
|
|
196
200
|
},
|
|
@@ -199,6 +203,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
199
203
|
fields: [
|
|
200
204
|
{ path: "classifier.ambiguityThreshold", label: "Ambiguity threshold", kind: "number", min: 0, max: 1 },
|
|
201
205
|
{ path: "classifier.model", label: "Adjudicator model", kind: "string", optional: true },
|
|
206
|
+
{ path: "classifier.learnedModelPath", label: "Learned risk model file", kind: "string", optional: true, hint: "from tools/train-classifier.ts; advisory" },
|
|
202
207
|
{ path: "classifier.maxCostFraction", label: "Max cost fraction", kind: "number", min: 0, max: 1 },
|
|
203
208
|
{ path: "classifier.maxCostUsd", label: "Max adjudication cost $", kind: "number", min: 0 },
|
|
204
209
|
{ path: "classifier.timeoutMs", label: "Adjudicator timeout", kind: "number", min: 1, hint: "ms" },
|
|
@@ -207,6 +212,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
207
212
|
{ path: "classifier.chatAxis", label: "Chat axis", kind: "enum", options: AXES },
|
|
208
213
|
{ path: "classifier.agenticLoopDepth", label: "Agentic loop depth", kind: "number", min: 0, hint: "tool rounds before damping" },
|
|
209
214
|
{ path: "classifier.mechanicalRetryFactor", label: "Mechanical retry factor", kind: "number", min: 0, max: 1 },
|
|
215
|
+
{ path: "classifier.readOnlyToolWeight", label: "Read-only tool loop weight", kind: "number", min: 0, hint: "0=record only" },
|
|
210
216
|
{ path: "classifier.reasoningWeights.medium", label: "Reasoning weight: medium", kind: "number", min: 0 },
|
|
211
217
|
{ path: "classifier.reasoningWeights.high", label: "Reasoning weight: high", kind: "number", min: 0 },
|
|
212
218
|
{ path: "classifier.reasoningWeights.xhigh", label: "Reasoning weight: xhigh", kind: "number", min: 0 },
|
|
@@ -300,9 +306,14 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
300
306
|
{ path: "budget.perTurnUsd", label: "Per-turn cap $", kind: "number", min: 0, optional: true },
|
|
301
307
|
{ path: "budget.perConversationUsd", label: "Per-conversation cap $", kind: "number", min: 0, optional: true },
|
|
302
308
|
{ path: "budget.perDayUsd", label: "Per-day cap $", kind: "number", min: 0, optional: true },
|
|
309
|
+
{ path: "budget.perMonthUsd", label: "Per-month target $", kind: "number", min: 0, optional: true, hint: "paced into a daily cap" },
|
|
303
310
|
{ path: "budget.onExceeded", label: "On exceeded", kind: "enum", options: ["downgrade", "reject"] },
|
|
304
311
|
],
|
|
305
312
|
},
|
|
313
|
+
{
|
|
314
|
+
title: "Report",
|
|
315
|
+
fields: [{ path: "report.baselines", label: "Counterfactual baseline models", kind: "stringArray", hint: "comma-separated slugs" }],
|
|
316
|
+
},
|
|
306
317
|
{
|
|
307
318
|
title: "Ledger",
|
|
308
319
|
fields: [
|
package/src/cli/report.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { loadConfig } from "../config/load.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { createCatalog } from "../catalog/openrouter-catalog.ts";
|
|
11
|
+
import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
|
|
11
12
|
import { openDb } from "../util/sqlite.ts";
|
|
12
13
|
import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
|
|
13
14
|
|
|
@@ -28,7 +29,11 @@ export async function reportCommand(args: CliArgs): Promise<void> {
|
|
|
28
29
|
|
|
29
30
|
const db = openDb(cfg.ledger.path);
|
|
30
31
|
try {
|
|
31
|
-
|
|
32
|
+
// Baseline prices from the cached catalog: no network for a report.
|
|
33
|
+
const dead = { dispatch: () => Promise.reject(new Error("offline")), complete: () => Promise.reject(new Error("offline")), fetchModels: () => Promise.reject(new Error("offline")), fetchModelsForUser: () => Promise.reject(new Error("offline")) };
|
|
34
|
+
const snapshot = createCatalog(cfg, dead, db).peek();
|
|
35
|
+
const baselines = baselinePrices(cfg.report.baselines, (s) => snapshot?.models.find((m) => m.slug === s));
|
|
36
|
+
const report = buildUsageReport(db, { windowDays: days, harnessId, baselines });
|
|
32
37
|
if (args.flags.has("json")) console.log(JSON.stringify(report, null, 2));
|
|
33
38
|
else console.log(renderUsageReport(report));
|
|
34
39
|
} finally {
|