auto-model-router 0.2.32 → 0.3.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.
Files changed (70) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +225 -29
  3. package/docs/review-2026-09-05.md +267 -0
  4. package/omp-extension/configure-logic.ts +71 -15
  5. package/omp-extension/pi-coding-agent.d.ts +79 -2
  6. package/omp-extension/report-hub.ts +376 -0
  7. package/omp-extension/report-logic.ts +117 -0
  8. package/omp-extension/router-configure.ts +203 -51
  9. package/omp-extension/router-url.ts +52 -0
  10. package/omp-extension/toast-logic.ts +14 -2
  11. package/package.json +1 -1
  12. package/src/catalog/composite.ts +97 -0
  13. package/src/catalog/ollama-catalog.ts +309 -0
  14. package/src/catalog/ollama-prices.ts +85 -0
  15. package/src/catalog/openrouter-catalog.ts +39 -1
  16. package/src/catalog/types.ts +31 -1
  17. package/src/cli/args.ts +1 -0
  18. package/src/cli/config-wizard.ts +190 -28
  19. package/src/cli/explain.ts +2 -4
  20. package/src/cli/models.ts +2 -4
  21. package/src/cli/report.ts +37 -0
  22. package/src/config/defaults.ts +46 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +28 -0
  26. package/src/config/types.ts +120 -2
  27. package/src/cost/cache-estimate.ts +52 -0
  28. package/src/cost/ledger.ts +73 -4
  29. package/src/cost/report.ts +351 -0
  30. package/src/cost/types.ts +39 -1
  31. package/src/index.ts +5 -8
  32. package/src/router/candidates.ts +52 -4
  33. package/src/router/classify.ts +33 -6
  34. package/src/router/features.ts +13 -1
  35. package/src/router/select.ts +55 -8
  36. package/src/router/state.ts +6 -2
  37. package/src/router/tier-plan.ts +49 -11
  38. package/src/router/types.ts +10 -0
  39. package/src/server/http.ts +50 -6
  40. package/src/server/providers.ts +54 -0
  41. package/src/server/turn.ts +138 -34
  42. package/src/tokens/estimate.ts +16 -0
  43. package/src/upstream/multi.ts +26 -0
  44. package/src/upstream/ollama-usage.ts +163 -0
  45. package/src/upstream/ollama.ts +275 -0
  46. package/src/upstream/openrouter.ts +19 -1
  47. package/src/upstream/types.ts +2 -0
  48. package/src/util/sqlite.ts +25 -1
  49. package/test/cache-estimate.test.ts +48 -0
  50. package/test/catalog.test.ts +44 -0
  51. package/test/classify.test.ts +41 -5
  52. package/test/compaction.test.ts +1 -0
  53. package/test/config-wizard.test.ts +77 -1
  54. package/test/configure-logic.test.ts +129 -33
  55. package/test/embed-lifecycle.test.ts +1 -0
  56. package/test/failover.test.ts +148 -3
  57. package/test/features.test.ts +35 -0
  58. package/test/http-resilience.test.ts +24 -0
  59. package/test/ollama.test.ts +521 -0
  60. package/test/omp-credentials.test.ts +43 -1
  61. package/test/report-hub.test.ts +343 -0
  62. package/test/report-logic.test.ts +93 -0
  63. package/test/report.test.ts +233 -0
  64. package/test/select.test.ts +151 -1
  65. package/test/tier-plan.test.ts +159 -1
  66. package/test/toast-logic.test.ts +11 -2
  67. package/test/tokens.test.ts +71 -1
  68. package/test/trust-attribution.test.ts +2 -2
  69. package/test/turn.test.ts +173 -7
  70. package/tools/recompute-ollama-cache.ts +129 -0
@@ -2,8 +2,9 @@
2
2
  * Interactive configuration wizard for `auto-model-router config`.
3
3
  *
4
4
  * Edits the router's OWN config (`~/.auto-model-router/config.yml`), covering every
5
- * section: server, openrouter, tiers, tasks, filters, classifier, escalation,
6
- * hysteresis, cache, budget, ledger, logging.
5
+ * section: server, openrouter, ollama, benchmarks, tiers, tasks, filters, classifier,
6
+ * escalation, hysteresis, exploration, cache, compaction, context, budget, ledger,
7
+ * logging. omp's `/router config` walks the same `WIZARD_SECTIONS`.
7
8
  *
8
9
  * Only fields the user actually changes are written, as a deep-merge partial,
9
10
  * so untouched defaults and hand-edited values survive.
@@ -36,16 +37,18 @@ export interface WizardIo {
36
37
  export interface FieldSpec {
37
38
  path: string;
38
39
  label: string;
39
- kind: "string" | "number" | "boolean" | "enum" | "stringArray";
40
- /** For `enum`: the allowed values. */
40
+ kind: "string" | "number" | "boolean" | "enum" | "stringArray" | "numberArray";
41
+ /** For `enum`: the allowed values. For `stringArray`: the allowed items, when restricted. */
41
42
  options?: readonly string[];
42
- /** For `number`: inclusive bounds. */
43
+ /** For `number` / `numberArray`: inclusive bounds. */
43
44
  min?: number;
44
45
  max?: number;
45
46
  /** Whether the field may be cleared to "no value". */
46
47
  optional?: boolean;
47
48
  /** Short hint shown with the label. */
48
49
  hint?: string;
50
+ /** Credential: the current value is shown as set/unset, never echoed. */
51
+ secret?: boolean;
49
52
  }
50
53
 
51
54
  /** A wizard section: a titled group of fields. */
@@ -55,28 +58,105 @@ export interface SectionSpec {
55
58
  }
56
59
 
57
60
  const AXES = ["coding", "agentic", "intelligence"] as const;
61
+ const TIER_NAMES = ["trivial", "simple", "moderate", "hard"] as const;
62
+ const TASK_NAMES = ["coding", "vision", "documentation", "data", "chat"] as const;
58
63
 
59
- /** Every field the wizard can edit, grouped into the menu's sections. */
64
+ /**
65
+ * Escalation triggers the orchestrator understands: the `EscalationSignal`
66
+ * union in `src/router/types.ts`. The schema accepts any string so an
67
+ * experimental trigger can still be added by hand in YAML.
68
+ */
69
+ const ESCALATION_TRIGGERS = [
70
+ "malformed_tool_args",
71
+ "refusal",
72
+ "empty_completion",
73
+ "repeat_tool_call",
74
+ "length_stop",
75
+ "missing_expected_tool_call",
76
+ "upstream_error",
77
+ ] as const;
78
+
79
+ function tierFields(tier: (typeof TIER_NAMES)[number]): FieldSpec[] {
80
+ const p = `tiers.${tier}`;
81
+ return [
82
+ { path: `${p}.minQuality`, label: `${tier}: min quality`, kind: "number", min: 0, max: 100 },
83
+ { path: `${p}.maxInputPerMtok`, label: `${tier}: max input $/Mtok`, kind: "number", min: 0, optional: true },
84
+ { path: `${p}.maxOutputPerMtok`, label: `${tier}: max output $/Mtok`, kind: "number", min: 0, optional: true },
85
+ { path: `${p}.qualityExponent`, label: `${tier}: quality exponent`, kind: "number", min: 0, hint: "quality^k per $" },
86
+ { path: `${p}.qualityNormalization`, label: `${tier}: normalise quality to floor`, kind: "boolean", optional: true },
87
+ { path: `${p}.capabilityFloorUsd`, label: `${tier}: capability floor $/Mtok`, kind: "number", min: 0, optional: true, hint: "blended" },
88
+ { path: `${p}.pin`, label: `${tier}: pinned slugs`, kind: "stringArray", hint: "comma-separated" },
89
+ ];
90
+ }
91
+
92
+ function taskFields(task: (typeof TASK_NAMES)[number]): FieldSpec[] {
93
+ const p = `tasks.${task}`;
94
+ return [
95
+ { path: `${p}.axis`, label: `${task}: axis`, kind: "enum", options: AXES },
96
+ { path: `${p}.minQuality`, label: `${task}: quality floor`, kind: "number", min: 0, max: 100, optional: true },
97
+ { path: `${p}.requireImage`, label: `${task}: require image input`, kind: "boolean", optional: true },
98
+ { path: `${p}.prefer`, label: `${task}: always-eligible slugs`, kind: "stringArray", optional: true, hint: "comma-separated" },
99
+ ];
100
+ }
101
+
102
+ /**
103
+ * Every field the wizard can edit, grouped into the menu's sections. This is
104
+ * the whole of `RouterConfig` except the two Ollama maps (`ollama.prices`,
105
+ * `ollama.twins`) and the profiles array, which are edited as records
106
+ * (profiles through the wizard's own profile editor, the maps in YAML).
107
+ * `test/config-wizard.test.ts` checks that every other config leaf is here.
108
+ */
60
109
  export const WIZARD_SECTIONS: readonly SectionSpec[] = [
61
110
  {
62
111
  title: "Server",
63
112
  fields: [
64
113
  { path: "server.host", label: "Listen host", kind: "string" },
65
114
  { path: "server.port", label: "Listen port", kind: "number", min: 1, max: 65535 },
66
- { path: "server.apiKey", label: "Client bearer token", kind: "string", optional: true },
115
+ { path: "server.apiKey", label: "Client bearer token", kind: "string", optional: true, secret: true },
67
116
  { path: "server.harnessId", label: "Default harness id", kind: "string", optional: true },
117
+ { path: "server.maxConcurrentTurns", label: "Max concurrent turns", kind: "number", min: 1, hint: "per process, all sessions" },
68
118
  ],
69
119
  },
70
120
  {
71
121
  title: "OpenRouter",
72
122
  fields: [
73
123
  { path: "openrouter.baseUrl", label: "Base URL", kind: "string" },
124
+ { path: "openrouter.apiKey", label: "API key", kind: "string", optional: true, secret: true, hint: "or OPENROUTER_API_KEY / omp login" },
125
+ { path: "openrouter.referer", label: "Attribution referer", kind: "string", optional: true },
74
126
  { path: "openrouter.title", label: "Attribution title", kind: "string" },
75
127
  { path: "openrouter.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
76
128
  { path: "openrouter.catalogTtlMs", label: "Catalog TTL", kind: "number", min: 1, hint: "ms" },
77
129
  { path: "openrouter.catalogRefreshMs", label: "Catalog refresh", kind: "number", min: 0, hint: "ms, 0=off" },
78
130
  ],
79
131
  },
132
+ {
133
+ title: "Ollama Cloud",
134
+ fields: [
135
+ { path: "ollama.enabled", label: "Enable Ollama Cloud as a second upstream", kind: "boolean" },
136
+ { path: "ollama.baseUrl", label: "Base URL", kind: "string", hint: "https://ollama.com/v1 or a local daemon" },
137
+ { path: "ollama.apiKey", label: "API key", kind: "string", optional: true, secret: true, hint: "or OLLAMA_API_KEY / omp login ollama-cloud" },
138
+ { path: "ollama.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
139
+ { path: "ollama.catalogTtlMs", label: "Catalog TTL", kind: "number", min: 1, hint: "ms" },
140
+ { path: "ollama.includeLocal", label: "Include locally pulled models", kind: "boolean", hint: "local daemon only" },
141
+ { path: "ollama.costBias", label: "Cost bias while credits remain", kind: "number", min: 0, hint: "0.1 = tenth the cost; 1 = at list" },
142
+ { path: "ollama.biasUntilUsage", label: "Apply bias until plan usage", kind: "number", min: 0, max: 1, hint: "0-1 of included credits" },
143
+ { path: "ollama.usagePollMs", label: "Plan usage poll", kind: "number", min: 0, hint: "ms, 0=off" },
144
+ { path: "ollama.quotaCooldownMs", label: "Quota (402) cooldown", kind: "number", min: 0, hint: "ms" },
145
+ { path: "ollama.rateLimitCooldownMs", label: "Rate-limit (429) cooldown", kind: "number", min: 0, hint: "ms" },
146
+ { path: "ollama.planCreditsUsd", label: "Plan credits per month $", kind: "number", min: 0, hint: "Pro 60, Max 300; 0=unknown" },
147
+ ],
148
+ },
149
+ {
150
+ title: "Benchmarks",
151
+ fields: [
152
+ { path: "benchmarks.enabled", label: "Fetch quality scores", kind: "boolean" },
153
+ { path: "benchmarks.artificialAnalysisApiKey", label: "Artificial Analysis API key", kind: "string", optional: true, secret: true },
154
+ { path: "benchmarks.benchlm", label: "Use BenchLM scores", kind: "boolean" },
155
+ { path: "benchmarks.refreshMs", label: "Refresh interval", kind: "number", min: 0, hint: "ms" },
156
+ { path: "benchmarks.timeoutMs", label: "Fetch timeout", kind: "number", min: 1, hint: "ms" },
157
+ { path: "benchmarks.useLocalScores", label: "Blend in local eval scores", kind: "boolean" },
158
+ ],
159
+ },
80
160
  {
81
161
  title: "Tiers",
82
162
  fields: [
@@ -86,30 +166,13 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
86
166
  kind: "boolean",
87
167
  hint: "keeps every tier populated",
88
168
  },
89
- { path: "tiers.trivial.minQuality", label: "trivial: min quality", kind: "number", min: 0, max: 100 },
90
- { path: "tiers.trivial.maxInputPerMtok", label: "trivial: max input $/Mtok", kind: "number", min: 0, optional: true },
91
- { path: "tiers.simple.minQuality", label: "simple: min quality", kind: "number", min: 0, max: 100 },
92
- { path: "tiers.simple.maxInputPerMtok", label: "simple: max input $/Mtok", kind: "number", min: 0, optional: true },
93
- { path: "tiers.moderate.minQuality", label: "moderate: min quality", kind: "number", min: 0, max: 100 },
94
- { path: "tiers.moderate.maxInputPerMtok", label: "moderate: max input $/Mtok", kind: "number", min: 0, optional: true },
95
- { path: "tiers.hard.minQuality", label: "hard: min quality", kind: "number", min: 0, max: 100 },
96
- { path: "tiers.hard.maxInputPerMtok", label: "hard: max input $/Mtok", kind: "number", min: 0, optional: true },
169
+ { path: "adaptivePriceCeilings", label: "Adaptive price ceilings", kind: "boolean", hint: "derive $/Mtok caps from the catalog" },
170
+ ...TIER_NAMES.flatMap(tierFields),
97
171
  ],
98
172
  },
99
173
  {
100
174
  title: "Tasks",
101
- fields: [
102
- { path: "tasks.coding.axis", label: "coding: axis", kind: "enum", options: AXES },
103
- { path: "tasks.coding.minQuality", label: "coding: quality floor", kind: "number", min: 0, max: 100, optional: true },
104
- { path: "tasks.vision.axis", label: "vision: axis", kind: "enum", options: AXES },
105
- { path: "tasks.vision.minQuality", label: "vision: quality floor", kind: "number", min: 0, max: 100, optional: true },
106
- { path: "tasks.documentation.axis", label: "documentation: axis", kind: "enum", options: AXES },
107
- { path: "tasks.documentation.minQuality", label: "documentation: quality floor", kind: "number", min: 0, max: 100, optional: true },
108
- { path: "tasks.data.axis", label: "data: axis", kind: "enum", options: AXES },
109
- { path: "tasks.data.minQuality", label: "data: quality floor", kind: "number", min: 0, max: 100, optional: true },
110
- { path: "tasks.chat.axis", label: "chat: axis", kind: "enum", options: AXES },
111
- { path: "tasks.chat.minQuality", label: "chat: quality floor", kind: "number", min: 0, max: 100, optional: true },
112
- ],
175
+ fields: TASK_NAMES.flatMap(taskFields),
113
176
  },
114
177
  {
115
178
  title: "Filters",
@@ -121,7 +184,14 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
121
184
  { path: "filters.minTrust", label: "Min trust", kind: "number", min: 0, max: 1 },
122
185
  { path: "filters.minTrustSamples", label: "Min trust samples", kind: "number", min: 0 },
123
186
  { path: "filters.trustScopedByHarness", label: "Scope trust per harness", kind: "boolean" },
187
+ { path: "filters.trustWindowDays", label: "Trust window", kind: "number", min: 0, hint: "days, 0=all time" },
124
188
  { path: "filters.contextHeadroom", label: "Context headroom", kind: "number", min: 1 },
189
+ { path: "filters.latencyWeight", label: "Latency weight", kind: "number", min: 0, hint: "0=ignore speed" },
190
+ { path: "filters.latencyReferenceMs", label: "Latency reference TTFT", kind: "number", min: 1, hint: "ms" },
191
+ { path: "filters.latencyReferenceTokensPerSec", label: "Latency reference speed", kind: "number", min: 1, hint: "tok/s" },
192
+ { path: "filters.latencyMinSamples", label: "Latency min samples", kind: "number", min: 0 },
193
+ { path: "filters.maxExpectedWaitMs", label: "Max expected wait", kind: "number", min: 1, optional: true, hint: "ms, hard ceiling" },
194
+ { path: "filters.escalationCostWeight", label: "Escalation cost weight", kind: "number", min: 0, max: 1 },
125
195
  ],
126
196
  },
127
197
  {
@@ -130,9 +200,17 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
130
200
  { path: "classifier.ambiguityThreshold", label: "Ambiguity threshold", kind: "number", min: 0, max: 1 },
131
201
  { path: "classifier.model", label: "Adjudicator model", kind: "string", optional: true },
132
202
  { path: "classifier.maxCostFraction", label: "Max cost fraction", kind: "number", min: 0, max: 1 },
203
+ { path: "classifier.maxCostUsd", label: "Max adjudication cost $", kind: "number", min: 0 },
133
204
  { path: "classifier.timeoutMs", label: "Adjudicator timeout", kind: "number", min: 1, hint: "ms" },
205
+ { path: "classifier.cacheSize", label: "Adjudication cache size", kind: "number", min: 0 },
134
206
  { path: "classifier.toolAxis", label: "Tool-call axis", kind: "enum", options: AXES },
135
207
  { path: "classifier.chatAxis", label: "Chat axis", kind: "enum", options: AXES },
208
+ { path: "classifier.agenticLoopDepth", label: "Agentic loop depth", kind: "number", min: 0, hint: "tool rounds before damping" },
209
+ { path: "classifier.mechanicalRetryFactor", label: "Mechanical retry factor", kind: "number", min: 0, max: 1 },
210
+ { path: "classifier.reasoningWeights.medium", label: "Reasoning weight: medium", kind: "number", min: 0 },
211
+ { path: "classifier.reasoningWeights.high", label: "Reasoning weight: high", kind: "number", min: 0 },
212
+ { path: "classifier.reasoningWeights.xhigh", label: "Reasoning weight: xhigh", kind: "number", min: 0 },
213
+ { path: "classifier.reasoningWeights.max", label: "Reasoning weight: max", kind: "number", min: 0 },
136
214
  ],
137
215
  },
138
216
  {
@@ -142,14 +220,34 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
142
220
  { path: "escalation.probeTokens", label: "Probe tokens", kind: "number", min: 1 },
143
221
  { path: "escalation.maxHoldMs", label: "Max hold", kind: "number", min: 1, hint: "ms" },
144
222
  { path: "escalation.maxAttempts", label: "Max attempts", kind: "number", min: 1 },
223
+ { path: "escalation.probeTiers", label: "Tiers that probe", kind: "stringArray", options: TIER_NAMES, hint: "comma-separated" },
224
+ { path: "escalation.triggers", label: "Triggers", kind: "stringArray", options: ESCALATION_TRIGGERS, hint: "comma-separated" },
225
+ { path: "escalation.escalateOnLengthStop", label: "Escalate on length stop", kind: "boolean" },
145
226
  ],
146
227
  },
147
228
  {
148
229
  title: "Hysteresis",
149
230
  fields: [
150
231
  { path: "hysteresis.holdTurns", label: "Hold turns", kind: "number", min: 0 },
232
+ { path: "hysteresis.holdTurnsAfterEscalation", label: "Hold turns after escalation", kind: "number", min: 0 },
151
233
  { path: "hysteresis.switchMargin", label: "Switch margin", kind: "number", min: 0 },
234
+ { path: "hysteresis.switchHorizonTurns", label: "Switch horizon", kind: "number", min: 1, hint: "turns amortised" },
152
235
  { path: "hysteresis.cacheWarmTtlMs", label: "Cache-warm TTL", kind: "number", min: 0, hint: "ms" },
236
+ { path: "hysteresis.maxDowngradePerTurn", label: "Max downgrade per turn", kind: "number", min: 0, hint: "tiers" },
237
+ { path: "hysteresis.breakHoldOnMechanical", label: "Break hold on mechanical turns", kind: "boolean" },
238
+ ],
239
+ },
240
+ {
241
+ title: "Exploration",
242
+ fields: [
243
+ { path: "exploration.enabled", label: "Enable exploration", kind: "boolean", hint: "records arms into the ledger" },
244
+ { path: "exploration.rates.trivial", label: "Rate: trivial", kind: "number", min: 0, max: 1, optional: true },
245
+ { path: "exploration.rates.simple", label: "Rate: simple", kind: "number", min: 0, max: 1, optional: true },
246
+ { path: "exploration.rates.moderate", label: "Rate: moderate", kind: "number", min: 0, max: 1, optional: true },
247
+ { path: "exploration.rates.hard", label: "Rate: hard", kind: "number", min: 0, max: 1, optional: true },
248
+ { path: "exploration.stickyPolicy", label: "Sticky policy", kind: "enum", options: ["never", "cold-cache", "always"] },
249
+ { path: "exploration.holdTurns.enabled", label: "Hold-length experiment", kind: "boolean" },
250
+ { path: "exploration.holdTurns.values", label: "Hold-length arms", kind: "numberArray", min: 1, hint: "comma-separated turns" },
153
251
  ],
154
252
  },
155
253
  {
@@ -158,6 +256,41 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
158
256
  { path: "cache.injectBreakpoints", label: "Inject cache breakpoints", kind: "boolean" },
159
257
  { path: "cache.maxBreakpoints", label: "Max breakpoints", kind: "number", min: 1 },
160
258
  { path: "cache.minPromptTokens", label: "Min prompt tokens", kind: "number", min: 0 },
259
+ { path: "cache.milestoneTokens", label: "Milestone tokens", kind: "number", min: 1, hint: "breakpoint spacing" },
260
+ ],
261
+ },
262
+ {
263
+ title: "Compaction",
264
+ fields: [
265
+ { path: "compaction.enabled", label: "Enable compaction", kind: "boolean" },
266
+ { path: "compaction.budgetTokens", label: "Budget tokens", kind: "number", min: 1 },
267
+ { path: "compaction.floorRatio", label: "Floor ratio", kind: "number", min: 0, max: 1, hint: "of the budget" },
268
+ { path: "compaction.replanGrowthRatio", label: "Replan growth ratio", kind: "number", min: 1 },
269
+ { path: "compaction.fitToWindow", label: "Fit to model context window", kind: "boolean" },
270
+ { path: "compaction.protectRecentTurns", label: "Protect recent turns", kind: "number", min: 1 },
271
+ { path: "compaction.maxToolResultBytes", label: "Max tool result bytes", kind: "number", min: 1 },
272
+ { path: "compaction.keepHeadBytes", label: "Keep head bytes", kind: "number", min: 0 },
273
+ { path: "compaction.keepTailBytes", label: "Keep tail bytes", kind: "number", min: 0 },
274
+ { path: "compaction.elideSupersededReads", label: "Elide superseded reads", kind: "boolean" },
275
+ { path: "compaction.collapseDuplicateResults", label: "Collapse duplicate results", kind: "boolean" },
276
+ ],
277
+ },
278
+ {
279
+ title: "Context (agentdox)",
280
+ fields: [
281
+ { path: "context.enabled", label: "Inject shared project context", kind: "boolean" },
282
+ { path: "context.baseUrl", label: "agentdox URL", kind: "string", optional: true },
283
+ { path: "context.token", label: "agentdox token", kind: "string", optional: true, secret: true, hint: "or AGENTDOX_TOKEN" },
284
+ { path: "context.defaultScope", label: "Default scope", kind: "string", optional: true },
285
+ { path: "context.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
286
+ { path: "context.maxStalenessMs", label: "Max staleness", kind: "number", min: 0, hint: "ms" },
287
+ { path: "context.maxBlockChars", label: "Max block chars", kind: "number", min: 1 },
288
+ { path: "context.memoryLimit", label: "Memory items", kind: "number", min: 1 },
289
+ { path: "context.docsLimit", label: "Doc items", kind: "number", min: 0 },
290
+ { path: "context.sessionLimit", label: "Session items", kind: "number", min: 0 },
291
+ { path: "context.briefChars", label: "Brief chars", kind: "number", min: 0 },
292
+ { path: "context.recordTurns", label: "Record turns back", kind: "boolean" },
293
+ { path: "context.maxQueue", label: "Write-back queue", kind: "number", min: 1 },
161
294
  ],
162
295
  },
163
296
  {
@@ -172,8 +305,11 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
172
305
  {
173
306
  title: "Ledger",
174
307
  fields: [
308
+ { path: "ledger.path", label: "Ledger path", kind: "string", hint: "SQLite file" },
175
309
  { path: "ledger.blendWindowDays", label: "Blend window", kind: "number", min: 1, hint: "days" },
176
310
  { path: "ledger.blendMinSamples", label: "Blend min samples", kind: "number", min: 0 },
311
+ { path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
312
+ { path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
177
313
  { path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
178
314
  ],
179
315
  },
@@ -258,8 +394,25 @@ export function validateField(field: FieldSpec, raw: string): FieldResult {
258
394
 
259
395
  case "stringArray": {
260
396
  const items = text.split(",").map((s) => s.trim()).filter((s) => s !== "");
397
+ if (field.options !== undefined) {
398
+ const bad = items.filter((s) => !field.options?.includes(s));
399
+ if (bad.length > 0) return { ok: false, error: `unknown: ${bad.join(", ")} (one of: ${field.options.join(", ")})` };
400
+ }
261
401
  return { ok: true, value: items };
262
402
  }
403
+
404
+ case "numberArray": {
405
+ const items = text.split(",").map((s) => s.trim()).filter((s) => s !== "");
406
+ const values: number[] = [];
407
+ for (const item of items) {
408
+ const n = Number(item);
409
+ if (!Number.isFinite(n)) return { ok: false, error: `not a number: ${item}` };
410
+ if (field.min !== undefined && n < field.min) return { ok: false, error: `${item}: must be >= ${field.min}` };
411
+ if (field.max !== undefined && n > field.max) return { ok: false, error: `${item}: must be <= ${field.max}` };
412
+ values.push(n);
413
+ }
414
+ return { ok: true, value: values };
415
+ }
263
416
  }
264
417
  }
265
418
 
@@ -321,10 +474,19 @@ export function formatValue(value: unknown): string {
321
474
  return String(value);
322
475
  }
323
476
 
477
+ /**
478
+ * What a prompt shows as the current value: `formatValue`, except that a
479
+ * secret is never echoed — only whether one is set.
480
+ */
481
+ export function displayValue(field: FieldSpec, value: unknown): string {
482
+ if (field.secret === true) return value === undefined || value === null || value === "" ? "unset" : "set";
483
+ return formatValue(value);
484
+ }
485
+
324
486
  /** Builds the field prompt line, e.g. ` Listen port [8788]: `. */
325
487
  function fieldPrompt(field: FieldSpec, current: unknown): string {
326
488
  const hint = field.hint !== undefined ? ` (${field.hint})` : "";
327
- return ` ${field.label}${hint} [${formatValue(current)}]: `;
489
+ return ` ${field.label}${hint} [${displayValue(field, current)}]: `;
328
490
  }
329
491
 
330
492
  /** Renders the top-level section menu. */
@@ -10,13 +10,12 @@
10
10
 
11
11
  import { existsSync } from "node:fs";
12
12
 
13
- import { createCatalog } from "../catalog/openrouter-catalog.ts";
13
+ import { createProviders } from "../server/providers.ts";
14
14
  import { loadConfig } from "../config/load.ts";
15
15
  import { createLedger } from "../cost/ledger.ts";
16
16
  import { createRouter } from "../router/index.ts";
17
17
  import { createConversationStore } from "../router/state.ts";
18
18
  import type { Candidate, Decision, Features, Rejection } from "../router/types.ts";
19
- import { createOpenRouterClient } from "../upstream/openrouter.ts";
20
19
  import { openDb } from "../util/sqlite.ts";
21
20
  import { parseChatRequest } from "../wire/openai/request.ts";
22
21
  import { configOpts, flagString, type CliArgs } from "./args.ts";
@@ -133,8 +132,7 @@ export async function explainCommand(args: CliArgs): Promise<void> {
133
132
  const db = openDb(cfg.ledger.path);
134
133
  try {
135
134
  const ledger = createLedger(db, cfg);
136
- const upstream = createOpenRouterClient(cfg);
137
- const catalog = createCatalog(cfg, upstream, db);
135
+ const { upstream, catalog } = createProviders(cfg, db);
138
136
  const conversations = createConversationStore(db);
139
137
  const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
140
138
 
package/src/cli/models.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { existsSync } from "node:fs";
11
11
  import type { Database } from "bun:sqlite";
12
12
 
13
- import { createCatalog } from "../catalog/openrouter-catalog.ts";
13
+ import { createProviders } from "../server/providers.ts";
14
14
  import { effectiveQualityFloor, tierPlanFor } from "../router/tier-plan.ts";
15
15
  import { loadConfig } from "../config/load.ts";
16
16
  import type { QualityAxis, RouterConfig } from "../config/types.ts";
@@ -21,7 +21,6 @@ import { classifyTask } from "../router/classify.ts";
21
21
  import { extractFeatures } from "../router/features.ts";
22
22
  import { TIER_ORDER, type Candidate, type Rejection, type Tier } from "../router/types.ts";
23
23
  import { estimatePromptTokens } from "../tokens/estimate.ts";
24
- import { createOpenRouterClient } from "../upstream/openrouter.ts";
25
24
  import { openDb } from "../util/sqlite.ts";
26
25
  import { parseChatRequest } from "../wire/openai/request.ts";
27
26
  import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
@@ -156,8 +155,7 @@ export async function modelsCommand(args: CliArgs): Promise<void> {
156
155
  const db: Database = openDb(cfg.ledger.path);
157
156
  const ledger: Ledger | null = existsSync(cfg.ledger.path) ? createLedger(db, cfg) : null;
158
157
  try {
159
- const upstream = createOpenRouterClient(cfg);
160
- const catalog = createCatalog(cfg, upstream, db);
158
+ const { catalog } = createProviders(cfg, db);
161
159
  const snapshot = await catalog.get();
162
160
 
163
161
  const req = syntheticRequest();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `auto-model-router report`: usage analytics over the ledger — which
3
+ * providers and models were routed, what they cost, how fast they were, how
4
+ * much of the prompt was served from cache, and the tier mix. The same
5
+ * aggregation backs `GET /v1/router/report` and omp's `/router report`.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { loadConfig } from "../config/load.ts";
10
+ import { buildUsageReport, renderUsageReport } from "../cost/report.ts";
11
+ import { openDb } from "../util/sqlite.ts";
12
+ import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
13
+
14
+ export async function reportCommand(args: CliArgs): Promise<void> {
15
+ const days = flagInt(args, "days") ?? 7;
16
+ const harnessId = flagString(args, "harness") ?? "";
17
+ const cfg = loadConfig(configOpts(args));
18
+
19
+ // Looking must not create the ledger file.
20
+ if (!existsSync(cfg.ledger.path)) {
21
+ if (args.flags.has("json")) {
22
+ console.log(JSON.stringify({ windowDays: days, harnessId, totals: null, providers: [], models: [], tiers: [], days: [] }, null, 2));
23
+ } else {
24
+ console.log(`no ledger at ${cfg.ledger.path} yet`);
25
+ }
26
+ return;
27
+ }
28
+
29
+ const db = openDb(cfg.ledger.path);
30
+ try {
31
+ const report = buildUsageReport(db, { windowDays: days, harnessId });
32
+ if (args.flags.has("json")) console.log(JSON.stringify(report, null, 2));
33
+ else console.log(renderUsageReport(report));
34
+ } finally {
35
+ db.close();
36
+ }
37
+ }
@@ -31,6 +31,35 @@ export const DEFAULT_CONFIG: RouterConfig = {
31
31
  // guardrail changes are picked up without waiting for traffic + TTL.
32
32
  catalogRefreshMs: 5 * 60 * 1000,
33
33
  },
34
+ ollama: {
35
+ // Off: a second upstream changes what every turn can route to.
36
+ enabled: false,
37
+ // The local daemon proxies `:cloud` models under the account it is signed
38
+ // in to and publishes their context/capabilities in `/api/tags`. Point at
39
+ // `https://ollama.com/v1` (with `apiKey`) to skip the daemon.
40
+ baseUrl: "http://127.0.0.1:11434/v1",
41
+ apiKey: "",
42
+ timeoutMs: 600_000,
43
+ catalogTtlMs: 5 * 60 * 1000,
44
+ includeLocal: false,
45
+ prices: {},
46
+ twins: {},
47
+ costBias: 1,
48
+ // Bias off once 90% of the month's included credits are used: the last
49
+ // slice is left for the plan to absorb overage-free, and anything past it
50
+ // bills at list price, where OpenRouter is usually cheaper.
51
+ biasUntilUsage: 0.9,
52
+ // The usage endpoint aggregates with a lag and rounds to whole percents;
53
+ // polling faster than this learns nothing.
54
+ usagePollMs: 10 * 60 * 1000,
55
+ // Credits reset monthly; 15 minutes keeps a topped-up account from waiting long.
56
+ quotaCooldownMs: 15 * 60 * 1000,
57
+ // Concurrency caps clear as soon as an in-flight request finishes.
58
+ rateLimitCooldownMs: 60 * 1000,
59
+ // ollama.com reports usage as a share of the plan; the dollar figure on
60
+ // its dashboard is that share × the plan's credits. Unknown until set.
61
+ planCreditsUsd: 0,
62
+ },
34
63
  benchmarks: {
35
64
  // Keyless BenchLM alone fills real gaps, so this is on by default; the AA
36
65
  // feed only actually fires once a key is present (config or env).
@@ -89,6 +118,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
89
118
  // slow models (e.g. deepseek-v4-flash ~20 tok/s) fall under it.
90
119
  latencyReferenceTokensPerSec: 30,
91
120
  latencyMinSamples: 20,
121
+ // Off: pricing a model's measured escalation rate at what an escalated
122
+ // retry actually costs changes rankings, so it is opt-in after a replay
123
+ // run prices it. See FilterConfig.escalationCostWeight.
124
+ escalationCostWeight: 0,
92
125
  },
93
126
  classifier: {
94
127
  ambiguityThreshold: 0.6,
@@ -101,8 +134,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
101
134
  toolAxis: "coding",
102
135
  chatAxis: "intelligence",
103
136
  agenticLoopDepth: 3,
104
- // Shipped values, unchanged. See ClassifierConfig.reasoningWeights: a
105
- // harness that pins the level for a whole session turns these into a
137
+ // A mechanical retry (failed tool call + tool-result continuation) keeps
138
+ // only a fifth of the +0.26; a user-visible failure keeps the full weight.
139
+ mechanicalRetryFactor: 0.2,
140
+ // Shipped reasoning values, unchanged. See ClassifierConfig.reasoningWeights:
141
+ // a harness that pins the level for a whole session turns these into a
106
142
  // constant tier offset, in which case `medium` belongs near 0.
107
143
  reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
108
144
  },
@@ -135,6 +171,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
135
171
  holdTurns: 2,
136
172
  holdTurnsAfterEscalation: 4,
137
173
  switchMargin: 1.3,
174
+ // 1 = the shipped one-turn comparison. Raise to amortise a switch over the
175
+ // turns that follow it; see HysteresisConfig.switchHorizonTurns.
176
+ switchHorizonTurns: 1,
138
177
  // OpenRouter sticky sessions expire in 5-10 minutes.
139
178
  cacheWarmTtlMs: 300_000,
140
179
  maxDowngradePerTurn: 1,
@@ -226,6 +265,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
226
265
  // here — the same reason `enabled` is false. 0.75 is the recommended
227
266
  // setting once a deployment has watched its own ledger.
228
267
  floorRatio: 1,
268
+ // 1 = re-plan whenever the compacted prompt is over budget (the shipped
269
+ // behaviour). Above 1, a plan is only extended once the compacted prompt
270
+ // has grown by that factor since it was last planned; see
271
+ // CompactionConfig.replanGrowthRatio.
272
+ replanGrowthRatio: 1,
229
273
  fitToWindow: true,
230
274
  protectRecentTurns: 4,
231
275
  maxToolResultBytes: 4_096,
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import { DEFAULT_CONFIG } from "./defaults.ts";
6
6
  import { configInputSchema } from "./schema.ts";
7
- import { resolveOpenRouterKey, type ResolvedCredential } from "./omp-credentials.ts";
7
+ import { resolveOllamaKey, resolveOpenRouterKey, type ResolvedCredential } from "./omp-credentials.ts";
8
8
  import type { RouterConfig } from "./types.ts";
9
9
 
10
10
  const LOG_LEVELS: readonly RouterConfig["logLevel"][] = ["silent", "error", "warn", "info", "debug"];
@@ -103,6 +103,10 @@ export function loadConfig(opts?: { path?: string; overrides?: DeepPartial<Route
103
103
  if (envApiKey !== undefined && envApiKey !== "") putSection("openrouter", "apiKey", envApiKey);
104
104
  const envAaKey = process.env.ARTIFICIAL_ANALYSIS_API_KEY;
105
105
  if (envAaKey !== undefined && envAaKey !== "") putSection("benchmarks", "artificialAnalysisApiKey", envAaKey);
106
+ // Ollama's own convention for its cloud key. Only fills the key; enabling
107
+ // the upstream stays an explicit `ollama.enabled: true`.
108
+ const envOllamaKey = process.env.OLLAMA_API_KEY;
109
+ if (envOllamaKey !== undefined && envOllamaKey !== "") putSection("ollama", "apiKey", envOllamaKey);
106
110
  const envPort = process.env.AUTO_MODEL_ROUTER_PORT;
107
111
  if (envPort !== undefined && envPort !== "") {
108
112
  const port = Number.parseInt(envPort, 10);
@@ -151,9 +155,29 @@ export function loadConfig(opts?: { path?: string; overrides?: DeepPartial<Route
151
155
  cfg.openrouter.apiKey = credential.apiKey;
152
156
  apiKeyProvenance.set(cfg, credential);
153
157
 
158
+ // Same borrowing for Ollama Cloud: `/login ollama-cloud` in omp is all the
159
+ // setup a direct ollama.com connection needs. Resolved even when the
160
+ // upstream is disabled, so enabling it later needs no extra step.
161
+ const ollamaCredential = resolveOllamaKey(cfg.ollama.apiKey);
162
+ cfg.ollama.apiKey = ollamaCredential.apiKey;
163
+ ollamaKeyProvenance.set(cfg, ollamaCredential);
164
+
154
165
  return cfg;
155
166
  }
156
167
 
168
+ const ollamaKeyProvenance = new WeakMap<RouterConfig, ResolvedCredential>();
169
+
170
+ /** Where a config's Ollama Cloud key came from; never the key itself. */
171
+ export function ollamaKeySource(cfg: RouterConfig): ResolvedCredential {
172
+ return (
173
+ ollamaKeyProvenance.get(cfg) ?? {
174
+ apiKey: cfg.ollama.apiKey,
175
+ source: cfg.ollama.apiKey === "" ? "none" : "config",
176
+ detail: cfg.ollama.apiKey === "" ? "no key configured" : "config",
177
+ }
178
+ );
179
+ }
180
+
157
181
  /**
158
182
  * Where a config's OpenRouter key came from, for startup logs and `/health`.
159
183
  * Keyed weakly off the config object so the provenance never has to travel
@@ -95,23 +95,33 @@ export function readOmpCredential(provider: string, storePath = ompAuthStorePath
95
95
  }
96
96
  }
97
97
 
98
+ interface ProviderKeySpec {
99
+ /** omp's provider id in the auth store. */
100
+ ompProvider: string;
101
+ /** Environment variable the key may have come from. */
102
+ envVar: string;
103
+ /** What to tell the operator when nothing resolves. */
104
+ loginHint: string;
105
+ }
106
+
98
107
  /**
99
- * Full precedence chain for the OpenRouter key. Explicit configuration always
100
- * beats the borrowed credential, so a project can point at a different account
108
+ * Full precedence chain for a provider key: explicit configuration (attributed
109
+ * to the environment when it matches the env var) beats the credential
110
+ * borrowed from omp's store, so a project can point at a different account
101
111
  * without touching omp.
102
112
  */
103
- export function resolveOpenRouterKey(configured: string): ResolvedCredential {
113
+ function resolveProviderKey(configured: string, spec: ProviderKeySpec): ResolvedCredential {
104
114
  if (configured !== "") {
105
- const fromEnv = process.env.OPENROUTER_API_KEY;
115
+ const fromEnv = process.env[spec.envVar];
106
116
  const source: CredentialSource = fromEnv !== undefined && fromEnv === configured ? "env" : "config";
107
117
  return {
108
118
  apiKey: configured,
109
119
  source,
110
- detail: source === "env" ? "OPENROUTER_API_KEY" : "config file",
120
+ detail: source === "env" ? spec.envVar : "config file",
111
121
  };
112
122
  }
113
123
 
114
- const borrowed = readOmpCredential("openrouter");
124
+ const borrowed = readOmpCredential(spec.ompProvider);
115
125
  if (borrowed !== null) {
116
126
  return { apiKey: borrowed, source: "omp-auth-store", detail: `omp auth store (${ompAuthStorePath()})` };
117
127
  }
@@ -119,6 +129,20 @@ export function resolveOpenRouterKey(configured: string): ResolvedCredential {
119
129
  return {
120
130
  apiKey: "",
121
131
  source: "none",
122
- detail: "no key: set OPENROUTER_API_KEY, or run `/login openrouter` inside omp",
132
+ detail: `no key: set ${spec.envVar}, or run \`/login ${spec.loginHint}\` inside omp`,
123
133
  };
124
134
  }
135
+
136
+ export function resolveOpenRouterKey(configured: string): ResolvedCredential {
137
+ return resolveProviderKey(configured, { ompProvider: "openrouter", envVar: "OPENROUTER_API_KEY", loginHint: "openrouter" });
138
+ }
139
+
140
+ /**
141
+ * The Ollama Cloud key, borrowed from omp's `ollama-cloud` provider once
142
+ * `/login ollama-cloud` has been run there. Only needed when `ollama.baseUrl`
143
+ * points at ollama.com; the local daemon authenticates with its own sign-in
144
+ * and ignores a bearer.
145
+ */
146
+ export function resolveOllamaKey(configured: string): ResolvedCredential {
147
+ return resolveProviderKey(configured, { ompProvider: "ollama-cloud", envVar: "OLLAMA_API_KEY", loginHint: "ollama-cloud" });
148
+ }