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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +225 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +117 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +190 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +46 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +28 -0
- package/src/config/types.ts +120 -2
- package/src/cost/cache-estimate.ts +52 -0
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +351 -0
- package/src/cost/types.ts +39 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +52 -4
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +10 -0
- package/src/server/http.ts +50 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +138 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +163 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/cache-estimate.test.ts +48 -0
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +521 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +343 -0
- package/test/report-logic.test.ts +93 -0
- package/test/report.test.ts +233 -0
- package/test/select.test.ts +151 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +173 -7
- package/tools/recompute-ollama-cache.ts +129 -0
package/src/cli/config-wizard.ts
CHANGED
|
@@ -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,
|
|
6
|
-
* hysteresis, cache, budget, ledger,
|
|
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
|
-
/**
|
|
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: "
|
|
90
|
-
|
|
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} [${
|
|
489
|
+
return ` ${field.label}${hint} [${displayValue(field, current)}]: `;
|
|
328
490
|
}
|
|
329
491
|
|
|
330
492
|
/** Renders the top-level section menu. */
|
package/src/cli/explain.ts
CHANGED
|
@@ -10,13 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
12
|
|
|
13
|
-
import {
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -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
|
-
//
|
|
105
|
-
//
|
|
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,
|
package/src/config/load.ts
CHANGED
|
@@ -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
|
|
100
|
-
*
|
|
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
|
-
|
|
113
|
+
function resolveProviderKey(configured: string, spec: ProviderKeySpec): ResolvedCredential {
|
|
104
114
|
if (configured !== "") {
|
|
105
|
-
const fromEnv = process.env.
|
|
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" ?
|
|
120
|
+
detail: source === "env" ? spec.envVar : "config file",
|
|
111
121
|
};
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
const borrowed = readOmpCredential(
|
|
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:
|
|
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
|
+
}
|