auto-model-router 0.2.31 → 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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +211 -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 +115 -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 +189 -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 +43 -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 +127 -2
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +340 -0
- package/src/cost/types.ts +33 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +90 -11
- 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 +11 -0
- package/src/server/http.ts +47 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +122 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +157 -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/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 +506 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +341 -0
- package/test/report-logic.test.ts +92 -0
- package/test/report.test.ts +217 -0
- package/test/select.test.ts +176 -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 +124 -7
- package/tools/build-site.ts +1 -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,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
|
-
/**
|
|
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: "
|
|
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 },
|
|
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} [${
|
|
488
|
+
return ` ${field.label}${hint} [${displayValue(field, current)}]: `;
|
|
328
489
|
}
|
|
329
490
|
|
|
330
491
|
/** 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,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
|
-
//
|
|
105
|
-
//
|
|
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,
|
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
|
+
}
|