auto-model-router 0.2.32 → 0.3.0

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 (67) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +208 -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 +115 -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 +189 -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 +43 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +27 -0
  26. package/src/config/types.ts +114 -2
  27. package/src/cost/ledger.ts +73 -4
  28. package/src/cost/report.ts +340 -0
  29. package/src/cost/types.ts +33 -1
  30. package/src/index.ts +5 -8
  31. package/src/router/candidates.ts +52 -4
  32. package/src/router/classify.ts +33 -6
  33. package/src/router/features.ts +13 -1
  34. package/src/router/select.ts +55 -8
  35. package/src/router/state.ts +6 -2
  36. package/src/router/tier-plan.ts +49 -11
  37. package/src/router/types.ts +10 -0
  38. package/src/server/http.ts +47 -6
  39. package/src/server/providers.ts +54 -0
  40. package/src/server/turn.ts +122 -34
  41. package/src/tokens/estimate.ts +16 -0
  42. package/src/upstream/multi.ts +26 -0
  43. package/src/upstream/ollama-usage.ts +157 -0
  44. package/src/upstream/ollama.ts +275 -0
  45. package/src/upstream/openrouter.ts +19 -1
  46. package/src/upstream/types.ts +2 -0
  47. package/src/util/sqlite.ts +25 -1
  48. package/test/catalog.test.ts +44 -0
  49. package/test/classify.test.ts +41 -5
  50. package/test/compaction.test.ts +1 -0
  51. package/test/config-wizard.test.ts +77 -1
  52. package/test/configure-logic.test.ts +129 -33
  53. package/test/embed-lifecycle.test.ts +1 -0
  54. package/test/failover.test.ts +148 -3
  55. package/test/features.test.ts +35 -0
  56. package/test/http-resilience.test.ts +24 -0
  57. package/test/ollama.test.ts +506 -0
  58. package/test/omp-credentials.test.ts +43 -1
  59. package/test/report-hub.test.ts +341 -0
  60. package/test/report-logic.test.ts +92 -0
  61. package/test/report.test.ts +217 -0
  62. package/test/select.test.ts +151 -1
  63. package/test/tier-plan.test.ts +159 -1
  64. package/test/toast-logic.test.ts +11 -2
  65. package/test/tokens.test.ts +71 -1
  66. package/test/trust-attribution.test.ts +2 -2
  67. package/test/turn.test.ts +124 -7
@@ -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,104 @@ 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
+ ],
147
+ },
148
+ {
149
+ title: "Benchmarks",
150
+ fields: [
151
+ { path: "benchmarks.enabled", label: "Fetch quality scores", kind: "boolean" },
152
+ { path: "benchmarks.artificialAnalysisApiKey", label: "Artificial Analysis API key", kind: "string", optional: true, secret: true },
153
+ { path: "benchmarks.benchlm", label: "Use BenchLM scores", kind: "boolean" },
154
+ { path: "benchmarks.refreshMs", label: "Refresh interval", kind: "number", min: 0, hint: "ms" },
155
+ { path: "benchmarks.timeoutMs", label: "Fetch timeout", kind: "number", min: 1, hint: "ms" },
156
+ { path: "benchmarks.useLocalScores", label: "Blend in local eval scores", kind: "boolean" },
157
+ ],
158
+ },
80
159
  {
81
160
  title: "Tiers",
82
161
  fields: [
@@ -86,30 +165,13 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
86
165
  kind: "boolean",
87
166
  hint: "keeps every tier populated",
88
167
  },
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 },
168
+ { path: "adaptivePriceCeilings", label: "Adaptive price ceilings", kind: "boolean", hint: "derive $/Mtok caps from the catalog" },
169
+ ...TIER_NAMES.flatMap(tierFields),
97
170
  ],
98
171
  },
99
172
  {
100
173
  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
- ],
174
+ fields: TASK_NAMES.flatMap(taskFields),
113
175
  },
114
176
  {
115
177
  title: "Filters",
@@ -121,7 +183,14 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
121
183
  { path: "filters.minTrust", label: "Min trust", kind: "number", min: 0, max: 1 },
122
184
  { path: "filters.minTrustSamples", label: "Min trust samples", kind: "number", min: 0 },
123
185
  { path: "filters.trustScopedByHarness", label: "Scope trust per harness", kind: "boolean" },
186
+ { path: "filters.trustWindowDays", label: "Trust window", kind: "number", min: 0, hint: "days, 0=all time" },
124
187
  { path: "filters.contextHeadroom", label: "Context headroom", kind: "number", min: 1 },
188
+ { path: "filters.latencyWeight", label: "Latency weight", kind: "number", min: 0, hint: "0=ignore speed" },
189
+ { path: "filters.latencyReferenceMs", label: "Latency reference TTFT", kind: "number", min: 1, hint: "ms" },
190
+ { path: "filters.latencyReferenceTokensPerSec", label: "Latency reference speed", kind: "number", min: 1, hint: "tok/s" },
191
+ { path: "filters.latencyMinSamples", label: "Latency min samples", kind: "number", min: 0 },
192
+ { path: "filters.maxExpectedWaitMs", label: "Max expected wait", kind: "number", min: 1, optional: true, hint: "ms, hard ceiling" },
193
+ { path: "filters.escalationCostWeight", label: "Escalation cost weight", kind: "number", min: 0, max: 1 },
125
194
  ],
126
195
  },
127
196
  {
@@ -130,9 +199,17 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
130
199
  { path: "classifier.ambiguityThreshold", label: "Ambiguity threshold", kind: "number", min: 0, max: 1 },
131
200
  { path: "classifier.model", label: "Adjudicator model", kind: "string", optional: true },
132
201
  { path: "classifier.maxCostFraction", label: "Max cost fraction", kind: "number", min: 0, max: 1 },
202
+ { path: "classifier.maxCostUsd", label: "Max adjudication cost $", kind: "number", min: 0 },
133
203
  { path: "classifier.timeoutMs", label: "Adjudicator timeout", kind: "number", min: 1, hint: "ms" },
204
+ { path: "classifier.cacheSize", label: "Adjudication cache size", kind: "number", min: 0 },
134
205
  { path: "classifier.toolAxis", label: "Tool-call axis", kind: "enum", options: AXES },
135
206
  { path: "classifier.chatAxis", label: "Chat axis", kind: "enum", options: AXES },
207
+ { path: "classifier.agenticLoopDepth", label: "Agentic loop depth", kind: "number", min: 0, hint: "tool rounds before damping" },
208
+ { path: "classifier.mechanicalRetryFactor", label: "Mechanical retry factor", kind: "number", min: 0, max: 1 },
209
+ { path: "classifier.reasoningWeights.medium", label: "Reasoning weight: medium", kind: "number", min: 0 },
210
+ { path: "classifier.reasoningWeights.high", label: "Reasoning weight: high", kind: "number", min: 0 },
211
+ { path: "classifier.reasoningWeights.xhigh", label: "Reasoning weight: xhigh", kind: "number", min: 0 },
212
+ { path: "classifier.reasoningWeights.max", label: "Reasoning weight: max", kind: "number", min: 0 },
136
213
  ],
137
214
  },
138
215
  {
@@ -142,14 +219,34 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
142
219
  { path: "escalation.probeTokens", label: "Probe tokens", kind: "number", min: 1 },
143
220
  { path: "escalation.maxHoldMs", label: "Max hold", kind: "number", min: 1, hint: "ms" },
144
221
  { path: "escalation.maxAttempts", label: "Max attempts", kind: "number", min: 1 },
222
+ { path: "escalation.probeTiers", label: "Tiers that probe", kind: "stringArray", options: TIER_NAMES, hint: "comma-separated" },
223
+ { path: "escalation.triggers", label: "Triggers", kind: "stringArray", options: ESCALATION_TRIGGERS, hint: "comma-separated" },
224
+ { path: "escalation.escalateOnLengthStop", label: "Escalate on length stop", kind: "boolean" },
145
225
  ],
146
226
  },
147
227
  {
148
228
  title: "Hysteresis",
149
229
  fields: [
150
230
  { path: "hysteresis.holdTurns", label: "Hold turns", kind: "number", min: 0 },
231
+ { path: "hysteresis.holdTurnsAfterEscalation", label: "Hold turns after escalation", kind: "number", min: 0 },
151
232
  { path: "hysteresis.switchMargin", label: "Switch margin", kind: "number", min: 0 },
233
+ { path: "hysteresis.switchHorizonTurns", label: "Switch horizon", kind: "number", min: 1, hint: "turns amortised" },
152
234
  { path: "hysteresis.cacheWarmTtlMs", label: "Cache-warm TTL", kind: "number", min: 0, hint: "ms" },
235
+ { path: "hysteresis.maxDowngradePerTurn", label: "Max downgrade per turn", kind: "number", min: 0, hint: "tiers" },
236
+ { path: "hysteresis.breakHoldOnMechanical", label: "Break hold on mechanical turns", kind: "boolean" },
237
+ ],
238
+ },
239
+ {
240
+ title: "Exploration",
241
+ fields: [
242
+ { path: "exploration.enabled", label: "Enable exploration", kind: "boolean", hint: "records arms into the ledger" },
243
+ { path: "exploration.rates.trivial", label: "Rate: trivial", kind: "number", min: 0, max: 1, optional: true },
244
+ { path: "exploration.rates.simple", label: "Rate: simple", kind: "number", min: 0, max: 1, optional: true },
245
+ { path: "exploration.rates.moderate", label: "Rate: moderate", kind: "number", min: 0, max: 1, optional: true },
246
+ { path: "exploration.rates.hard", label: "Rate: hard", kind: "number", min: 0, max: 1, optional: true },
247
+ { path: "exploration.stickyPolicy", label: "Sticky policy", kind: "enum", options: ["never", "cold-cache", "always"] },
248
+ { path: "exploration.holdTurns.enabled", label: "Hold-length experiment", kind: "boolean" },
249
+ { path: "exploration.holdTurns.values", label: "Hold-length arms", kind: "numberArray", min: 1, hint: "comma-separated turns" },
153
250
  ],
154
251
  },
155
252
  {
@@ -158,6 +255,41 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
158
255
  { path: "cache.injectBreakpoints", label: "Inject cache breakpoints", kind: "boolean" },
159
256
  { path: "cache.maxBreakpoints", label: "Max breakpoints", kind: "number", min: 1 },
160
257
  { path: "cache.minPromptTokens", label: "Min prompt tokens", kind: "number", min: 0 },
258
+ { path: "cache.milestoneTokens", label: "Milestone tokens", kind: "number", min: 1, hint: "breakpoint spacing" },
259
+ ],
260
+ },
261
+ {
262
+ title: "Compaction",
263
+ fields: [
264
+ { path: "compaction.enabled", label: "Enable compaction", kind: "boolean" },
265
+ { path: "compaction.budgetTokens", label: "Budget tokens", kind: "number", min: 1 },
266
+ { path: "compaction.floorRatio", label: "Floor ratio", kind: "number", min: 0, max: 1, hint: "of the budget" },
267
+ { path: "compaction.replanGrowthRatio", label: "Replan growth ratio", kind: "number", min: 1 },
268
+ { path: "compaction.fitToWindow", label: "Fit to model context window", kind: "boolean" },
269
+ { path: "compaction.protectRecentTurns", label: "Protect recent turns", kind: "number", min: 1 },
270
+ { path: "compaction.maxToolResultBytes", label: "Max tool result bytes", kind: "number", min: 1 },
271
+ { path: "compaction.keepHeadBytes", label: "Keep head bytes", kind: "number", min: 0 },
272
+ { path: "compaction.keepTailBytes", label: "Keep tail bytes", kind: "number", min: 0 },
273
+ { path: "compaction.elideSupersededReads", label: "Elide superseded reads", kind: "boolean" },
274
+ { path: "compaction.collapseDuplicateResults", label: "Collapse duplicate results", kind: "boolean" },
275
+ ],
276
+ },
277
+ {
278
+ title: "Context (agentdox)",
279
+ fields: [
280
+ { path: "context.enabled", label: "Inject shared project context", kind: "boolean" },
281
+ { path: "context.baseUrl", label: "agentdox URL", kind: "string", optional: true },
282
+ { path: "context.token", label: "agentdox token", kind: "string", optional: true, secret: true, hint: "or AGENTDOX_TOKEN" },
283
+ { path: "context.defaultScope", label: "Default scope", kind: "string", optional: true },
284
+ { path: "context.timeoutMs", label: "Request timeout", kind: "number", min: 1, hint: "ms" },
285
+ { path: "context.maxStalenessMs", label: "Max staleness", kind: "number", min: 0, hint: "ms" },
286
+ { path: "context.maxBlockChars", label: "Max block chars", kind: "number", min: 1 },
287
+ { path: "context.memoryLimit", label: "Memory items", kind: "number", min: 1 },
288
+ { path: "context.docsLimit", label: "Doc items", kind: "number", min: 0 },
289
+ { path: "context.sessionLimit", label: "Session items", kind: "number", min: 0 },
290
+ { path: "context.briefChars", label: "Brief chars", kind: "number", min: 0 },
291
+ { path: "context.recordTurns", label: "Record turns back", kind: "boolean" },
292
+ { path: "context.maxQueue", label: "Write-back queue", kind: "number", min: 1 },
161
293
  ],
162
294
  },
163
295
  {
@@ -172,8 +304,11 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
172
304
  {
173
305
  title: "Ledger",
174
306
  fields: [
307
+ { path: "ledger.path", label: "Ledger path", kind: "string", hint: "SQLite file" },
175
308
  { path: "ledger.blendWindowDays", label: "Blend window", kind: "number", min: 1, hint: "days" },
176
309
  { path: "ledger.blendMinSamples", label: "Blend min samples", kind: "number", min: 0 },
310
+ { path: "ledger.fallbackBlend.inputPerMtok", label: "Fallback blend input $/Mtok", kind: "number", min: 0 },
311
+ { path: "ledger.fallbackBlend.outputPerMtok", label: "Fallback blend output $/Mtok", kind: "number", min: 0 },
177
312
  { path: "ledger.conversationTtlMs", label: "Conversation TTL", kind: "number", min: 1, hint: "ms" },
178
313
  ],
179
314
  },
@@ -258,8 +393,25 @@ export function validateField(field: FieldSpec, raw: string): FieldResult {
258
393
 
259
394
  case "stringArray": {
260
395
  const items = text.split(",").map((s) => s.trim()).filter((s) => s !== "");
396
+ if (field.options !== undefined) {
397
+ const bad = items.filter((s) => !field.options?.includes(s));
398
+ if (bad.length > 0) return { ok: false, error: `unknown: ${bad.join(", ")} (one of: ${field.options.join(", ")})` };
399
+ }
261
400
  return { ok: true, value: items };
262
401
  }
402
+
403
+ case "numberArray": {
404
+ const items = text.split(",").map((s) => s.trim()).filter((s) => s !== "");
405
+ const values: number[] = [];
406
+ for (const item of items) {
407
+ const n = Number(item);
408
+ if (!Number.isFinite(n)) return { ok: false, error: `not a number: ${item}` };
409
+ if (field.min !== undefined && n < field.min) return { ok: false, error: `${item}: must be >= ${field.min}` };
410
+ if (field.max !== undefined && n > field.max) return { ok: false, error: `${item}: must be <= ${field.max}` };
411
+ values.push(n);
412
+ }
413
+ return { ok: true, value: values };
414
+ }
263
415
  }
264
416
  }
265
417
 
@@ -321,10 +473,19 @@ export function formatValue(value: unknown): string {
321
473
  return String(value);
322
474
  }
323
475
 
476
+ /**
477
+ * What a prompt shows as the current value: `formatValue`, except that a
478
+ * secret is never echoed — only whether one is set.
479
+ */
480
+ export function displayValue(field: FieldSpec, value: unknown): string {
481
+ if (field.secret === true) return value === undefined || value === null || value === "" ? "unset" : "set";
482
+ return formatValue(value);
483
+ }
484
+
324
485
  /** Builds the field prompt line, e.g. ` Listen port [8788]: `. */
325
486
  function fieldPrompt(field: FieldSpec, current: unknown): string {
326
487
  const hint = field.hint !== undefined ? ` (${field.hint})` : "";
327
- return ` ${field.label}${hint} [${formatValue(current)}]: `;
488
+ return ` ${field.label}${hint} [${displayValue(field, current)}]: `;
328
489
  }
329
490
 
330
491
  /** 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,32 @@ 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
+ },
34
60
  benchmarks: {
35
61
  // Keyless BenchLM alone fills real gaps, so this is on by default; the AA
36
62
  // feed only actually fires once a key is present (config or env).
@@ -89,6 +115,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
89
115
  // slow models (e.g. deepseek-v4-flash ~20 tok/s) fall under it.
90
116
  latencyReferenceTokensPerSec: 30,
91
117
  latencyMinSamples: 20,
118
+ // Off: pricing a model's measured escalation rate at what an escalated
119
+ // retry actually costs changes rankings, so it is opt-in after a replay
120
+ // run prices it. See FilterConfig.escalationCostWeight.
121
+ escalationCostWeight: 0,
92
122
  },
93
123
  classifier: {
94
124
  ambiguityThreshold: 0.6,
@@ -101,8 +131,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
101
131
  toolAxis: "coding",
102
132
  chatAxis: "intelligence",
103
133
  agenticLoopDepth: 3,
104
- // Shipped values, unchanged. See ClassifierConfig.reasoningWeights: a
105
- // harness that pins the level for a whole session turns these into a
134
+ // A mechanical retry (failed tool call + tool-result continuation) keeps
135
+ // only a fifth of the +0.26; a user-visible failure keeps the full weight.
136
+ mechanicalRetryFactor: 0.2,
137
+ // Shipped reasoning values, unchanged. See ClassifierConfig.reasoningWeights:
138
+ // a harness that pins the level for a whole session turns these into a
106
139
  // constant tier offset, in which case `medium` belongs near 0.
107
140
  reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
108
141
  },
@@ -135,6 +168,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
135
168
  holdTurns: 2,
136
169
  holdTurnsAfterEscalation: 4,
137
170
  switchMargin: 1.3,
171
+ // 1 = the shipped one-turn comparison. Raise to amortise a switch over the
172
+ // turns that follow it; see HysteresisConfig.switchHorizonTurns.
173
+ switchHorizonTurns: 1,
138
174
  // OpenRouter sticky sessions expire in 5-10 minutes.
139
175
  cacheWarmTtlMs: 300_000,
140
176
  maxDowngradePerTurn: 1,
@@ -226,6 +262,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
226
262
  // here — the same reason `enabled` is false. 0.75 is the recommended
227
263
  // setting once a deployment has watched its own ledger.
228
264
  floorRatio: 1,
265
+ // 1 = re-plan whenever the compacted prompt is over budget (the shipped
266
+ // behaviour). Above 1, a plan is only extended once the compacted prompt
267
+ // has grown by that factor since it was last planned; see
268
+ // CompactionConfig.replanGrowthRatio.
269
+ replanGrowthRatio: 1,
229
270
  fitToWindow: true,
230
271
  protectRecentTurns: 4,
231
272
  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
+ }