@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +72 -0
- package/install +75 -0
- package/package.json +92 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1030 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +628 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1273 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_OFFLINE_SETTINGS,
|
|
5
|
+
MINIMUM_OFFLINE_CONTEXT,
|
|
6
|
+
isLoopbackURL,
|
|
7
|
+
type OfflineSettings,
|
|
8
|
+
} from "./offline/mode.js"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import type {
|
|
11
|
+
CostTier,
|
|
12
|
+
ModelDefinition,
|
|
13
|
+
ModelSettings,
|
|
14
|
+
PremiumSettings,
|
|
15
|
+
PrivacySettings,
|
|
16
|
+
ProviderSettings,
|
|
17
|
+
RouterSettings,
|
|
18
|
+
} from "./types.js"
|
|
19
|
+
|
|
20
|
+
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
|
|
21
|
+
|
|
22
|
+
export interface SettingsLoadResult {
|
|
23
|
+
settings: RouterSettings
|
|
24
|
+
warnings: string[]
|
|
25
|
+
sources: string[]
|
|
26
|
+
/**
|
|
27
|
+
* Provider ids whose `enabled` the user set explicitly, in plugin options or
|
|
28
|
+
* in a config file.
|
|
29
|
+
*
|
|
30
|
+
* Credential detection fills in the providers a user never mentioned, but it
|
|
31
|
+
* must never overrule one they did. Without this set, "I turned OpenDesign
|
|
32
|
+
* off" and "I never configured OpenDesign" are indistinguishable after the
|
|
33
|
+
* merge, and detection would silently switch a deliberate choice back on.
|
|
34
|
+
*/
|
|
35
|
+
explicitProviderEnable: ReadonlySet<string>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Provider ids with an explicit boolean `enabled` in a config document. Reads
|
|
40
|
+
* the raw input rather than the merged settings, because the merge has already
|
|
41
|
+
* lost the distinction between "set to true" and "defaulted to true".
|
|
42
|
+
*/
|
|
43
|
+
const explicitProviderEnable = (input: unknown): Set<string> => {
|
|
44
|
+
const found = new Set<string>()
|
|
45
|
+
if (!isRecord(input)) return found
|
|
46
|
+
const providers = input.providers
|
|
47
|
+
if (!isRecord(providers)) return found
|
|
48
|
+
for (const [id, value] of Object.entries(providers)) {
|
|
49
|
+
if (isRecord(value) && typeof value.enabled === "boolean") found.add(id)
|
|
50
|
+
}
|
|
51
|
+
return found
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const providerDefaults = (plan: string, extra: Partial<ProviderSettings> = {}): ProviderSettings => ({
|
|
55
|
+
enabled: true,
|
|
56
|
+
plan,
|
|
57
|
+
reserveMonthlyPct: 10,
|
|
58
|
+
reserveWeeklyPct: 15,
|
|
59
|
+
reserveFiveHourPct: 10,
|
|
60
|
+
allowExtraUsage: false,
|
|
61
|
+
probeUsage: true,
|
|
62
|
+
...extra,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_SETTINGS: RouterSettings = {
|
|
66
|
+
enabled: true,
|
|
67
|
+
mode: "balanced",
|
|
68
|
+
dryRun: false,
|
|
69
|
+
providers: {
|
|
70
|
+
// OpenCode Go: per-model monthly allowances. The plan catalog holds the
|
|
71
|
+
// per-model table; the provider settings only carry policy.
|
|
72
|
+
"opencode-go": providerDefaults("go"),
|
|
73
|
+
commandcode: providerDefaults("goat", {
|
|
74
|
+
probeUsage: true,
|
|
75
|
+
monthlyCreditsUsd: 70,
|
|
76
|
+
fiveHourCapUsd: 14,
|
|
77
|
+
weeklyCapUsd: 35,
|
|
78
|
+
}),
|
|
79
|
+
// OpenDesign is optional and disabled by default. A user without
|
|
80
|
+
// OpenDesign credentials sees no behavior change at all.
|
|
81
|
+
//
|
|
82
|
+
// Pro is the default assumed tier because it is the only tier with a
|
|
83
|
+
// separate plan-model pool; the catalog is authoritative for the numbers
|
|
84
|
+
// and the live gateway is authoritative for entitlement.
|
|
85
|
+
// Ollama is a local provider. It is enabled so offline mode can reach it,
|
|
86
|
+
// and its models stay unapproved so online routing never picks them. The
|
|
87
|
+
// provider being enabled is not the same as its models being approved.
|
|
88
|
+
ollama: providerDefaults("local", { probeUsage: false, allowExtraUsage: false }),
|
|
89
|
+
opendesign: providerDefaults("pro", {
|
|
90
|
+
enabled: false,
|
|
91
|
+
probeUsage: true,
|
|
92
|
+
reserveMonthlyPct: 5,
|
|
93
|
+
reserveWeeklyPct: 10,
|
|
94
|
+
reserveFiveHourPct: 0,
|
|
95
|
+
allowExtraUsage: false,
|
|
96
|
+
subscriptionOnly: true,
|
|
97
|
+
providerScopedQuota: true,
|
|
98
|
+
// Pro has no five-hour Design Plan window.
|
|
99
|
+
ignoreFiveHourWindow: true,
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
privacy: {
|
|
103
|
+
allowTrainingModels: false,
|
|
104
|
+
allowFreeModels: false,
|
|
105
|
+
requireZdr: false,
|
|
106
|
+
},
|
|
107
|
+
premium: {
|
|
108
|
+
mode: "ask",
|
|
109
|
+
monthlyBudgetUsd: 0,
|
|
110
|
+
oneShotApproval: true,
|
|
111
|
+
},
|
|
112
|
+
routing: {
|
|
113
|
+
intelligenceFloor: 39.5,
|
|
114
|
+
designFloor: 60,
|
|
115
|
+
sophisticatedDesignFloor: 75,
|
|
116
|
+
switchHysteresisPct: 20,
|
|
117
|
+
largeContextTokens: 300_000,
|
|
118
|
+
largeContextHysteresisPct: 35,
|
|
119
|
+
maxEscalationsPerTask: 2,
|
|
120
|
+
preferSameProvider: true,
|
|
121
|
+
failClosed: true,
|
|
122
|
+
protectEscalationPool: true,
|
|
123
|
+
longContextFloorTokens: 1_000_000,
|
|
124
|
+
calibrateFloor: false,
|
|
125
|
+
},
|
|
126
|
+
quota: {
|
|
127
|
+
probeOpenCodeGo: true,
|
|
128
|
+
probeCommandCode: true,
|
|
129
|
+
probeOpenDesign: true,
|
|
130
|
+
probeIntervalMinutes: 5,
|
|
131
|
+
bootstrapSessions: 100,
|
|
132
|
+
bootstrapDays: 31,
|
|
133
|
+
},
|
|
134
|
+
contextHealth: {
|
|
135
|
+
enabled: true,
|
|
136
|
+
warnAtTokens: 400_000,
|
|
137
|
+
recommendAtTokens: 700_000,
|
|
138
|
+
},
|
|
139
|
+
// Preserve full context by default. Compaction is a destructive operation
|
|
140
|
+
// that only ever existed to survive a small allowance.
|
|
141
|
+
contextManagement: {
|
|
142
|
+
mode: "native",
|
|
143
|
+
enabled: true,
|
|
144
|
+
compactAtTokens: 0,
|
|
145
|
+
cooldownMinutes: 10,
|
|
146
|
+
allowEmergencyCompaction: true,
|
|
147
|
+
},
|
|
148
|
+
speed: {
|
|
149
|
+
enabled: true,
|
|
150
|
+
weightPct: 15,
|
|
151
|
+
minSamples: 3,
|
|
152
|
+
slowRouteMultiple: 2.5,
|
|
153
|
+
},
|
|
154
|
+
ui: {
|
|
155
|
+
// Off by default. A plugin that renames a stranger's chats on install is a
|
|
156
|
+
// bug, not a feature: the session title is the user's, and status belongs in
|
|
157
|
+
// the desktop panel or the terminal's status line. Turning this on is an
|
|
158
|
+
// explicit choice, and turning it back off restores the original names.
|
|
159
|
+
titleIndicator: false,
|
|
160
|
+
notifications: true,
|
|
161
|
+
compactMode: false,
|
|
162
|
+
compact: false,
|
|
163
|
+
},
|
|
164
|
+
logging: {
|
|
165
|
+
storePromptText: false,
|
|
166
|
+
decisionLogSize: 100,
|
|
167
|
+
diagnostics: true,
|
|
168
|
+
},
|
|
169
|
+
models: {
|
|
170
|
+
approved: [],
|
|
171
|
+
excluded: [],
|
|
172
|
+
tierOverrides: {},
|
|
173
|
+
definitions: [],
|
|
174
|
+
},
|
|
175
|
+
// Offline mode is off unless explicitly enabled. It lives here so that a
|
|
176
|
+
// caller reading DEFAULT_SETTINGS can see the full shape without having to
|
|
177
|
+
// know that offline is optional.
|
|
178
|
+
offline: DEFAULT_OFFLINE_SETTINGS,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const numberOr = (value: unknown): number | undefined => {
|
|
182
|
+
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
183
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
184
|
+
const parsed = Number(value)
|
|
185
|
+
if (Number.isFinite(parsed)) return parsed
|
|
186
|
+
}
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const timeValue = (value: unknown): number | undefined => {
|
|
191
|
+
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
192
|
+
if (typeof value === "string") {
|
|
193
|
+
const parsed = Date.parse(value)
|
|
194
|
+
if (Number.isFinite(parsed)) return parsed
|
|
195
|
+
}
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
200
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
201
|
+
|
|
202
|
+
const asBoolean = (value: unknown, fallback: boolean, warnings: string[], path: string): boolean => {
|
|
203
|
+
if (typeof value === "boolean") return value
|
|
204
|
+
warnings.push(`${path} must be a boolean; using ${fallback}`)
|
|
205
|
+
return fallback
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const asNumber = (
|
|
209
|
+
value: unknown,
|
|
210
|
+
fallback: number,
|
|
211
|
+
warnings: string[],
|
|
212
|
+
path: string,
|
|
213
|
+
min: number,
|
|
214
|
+
max: number,
|
|
215
|
+
): number => {
|
|
216
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
217
|
+
warnings.push(`${path} must be a finite number; using ${fallback}`)
|
|
218
|
+
return fallback
|
|
219
|
+
}
|
|
220
|
+
const clamped = Math.min(max, Math.max(min, value))
|
|
221
|
+
if (clamped !== value) warnings.push(`${path} was clamped to ${clamped}`)
|
|
222
|
+
return clamped
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const asString = (value: unknown, fallback: string, warnings: string[], path: string): string => {
|
|
226
|
+
if (typeof value === "string" && value.trim().length > 0) return value.trim()
|
|
227
|
+
warnings.push(`${path} must be a non-empty string; using ${fallback}`)
|
|
228
|
+
return fallback
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const asEnum = <T extends string>(
|
|
232
|
+
value: unknown,
|
|
233
|
+
values: ReadonlyArray<T>,
|
|
234
|
+
fallback: T,
|
|
235
|
+
warnings: string[],
|
|
236
|
+
path: string,
|
|
237
|
+
): T => {
|
|
238
|
+
if (typeof value === "string" && (values as ReadonlyArray<string>).includes(value)) return value as T
|
|
239
|
+
warnings.push(`${path} must be one of ${values.join(", ")}; using ${fallback}`)
|
|
240
|
+
return fallback
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const asStringArray = (value: unknown, fallback: string[], warnings: string[], path: string): string[] => {
|
|
244
|
+
if (value === undefined) return fallback
|
|
245
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
246
|
+
warnings.push(`${path} must be an array of strings; using the previous value`)
|
|
247
|
+
return fallback
|
|
248
|
+
}
|
|
249
|
+
return [...new Set(value.map((item) => item.trim()).filter(Boolean))]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const validateProvider = (
|
|
253
|
+
value: unknown,
|
|
254
|
+
base: ProviderSettings,
|
|
255
|
+
warnings: string[],
|
|
256
|
+
path: string,
|
|
257
|
+
): ProviderSettings => {
|
|
258
|
+
if (!isRecord(value)) {
|
|
259
|
+
warnings.push(`${path} must be an object; using defaults`)
|
|
260
|
+
return base
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
enabled: asBoolean(value.enabled, base.enabled, warnings, `${path}.enabled`),
|
|
264
|
+
plan: asString(value.plan, base.plan, warnings, `${path}.plan`),
|
|
265
|
+
reserveMonthlyPct: asNumber(value.reserveMonthlyPct, base.reserveMonthlyPct, warnings, `${path}.reserveMonthlyPct`, 0, 90),
|
|
266
|
+
reserveWeeklyPct: asNumber(value.reserveWeeklyPct, base.reserveWeeklyPct, warnings, `${path}.reserveWeeklyPct`, 0, 90),
|
|
267
|
+
reserveFiveHourPct: asNumber(value.reserveFiveHourPct, base.reserveFiveHourPct, warnings, `${path}.reserveFiveHourPct`, 0, 90),
|
|
268
|
+
allowExtraUsage: asBoolean(value.allowExtraUsage, base.allowExtraUsage, warnings, `${path}.allowExtraUsage`),
|
|
269
|
+
probeUsage: asBoolean(value.probeUsage, base.probeUsage, warnings, `${path}.probeUsage`),
|
|
270
|
+
monthlyCreditsUsd:
|
|
271
|
+
value.monthlyCreditsUsd === undefined
|
|
272
|
+
? base.monthlyCreditsUsd
|
|
273
|
+
: asNumber(value.monthlyCreditsUsd, base.monthlyCreditsUsd ?? 0, warnings, `${path}.monthlyCreditsUsd`, 0, 1_000_000),
|
|
274
|
+
fiveHourCapUsd:
|
|
275
|
+
value.fiveHourCapUsd === undefined
|
|
276
|
+
? base.fiveHourCapUsd
|
|
277
|
+
: asNumber(value.fiveHourCapUsd, base.fiveHourCapUsd ?? 0, warnings, `${path}.fiveHourCapUsd`, 0, 1_000_000),
|
|
278
|
+
weeklyCapUsd:
|
|
279
|
+
value.weeklyCapUsd === undefined
|
|
280
|
+
? base.weeklyCapUsd
|
|
281
|
+
: asNumber(value.weeklyCapUsd, base.weeklyCapUsd ?? 0, warnings, `${path}.weeklyCapUsd`, 0, 1_000_000),
|
|
282
|
+
credentialPath:
|
|
283
|
+
value.credentialPath === undefined
|
|
284
|
+
? base.credentialPath
|
|
285
|
+
: asString(value.credentialPath, base.credentialPath ?? "", warnings, `${path}.credentialPath`) || undefined,
|
|
286
|
+
credentialSource:
|
|
287
|
+
value.credentialSource === undefined
|
|
288
|
+
? base.credentialSource
|
|
289
|
+
: asEnum(
|
|
290
|
+
value.credentialSource,
|
|
291
|
+
["explicit", "auto", "off"] as const,
|
|
292
|
+
base.credentialSource ?? "explicit",
|
|
293
|
+
warnings,
|
|
294
|
+
`${path}.credentialSource`,
|
|
295
|
+
),
|
|
296
|
+
baseURL:
|
|
297
|
+
value.baseURL === undefined
|
|
298
|
+
? base.baseURL
|
|
299
|
+
: asString(value.baseURL, base.baseURL ?? "", warnings, `${path}.baseURL`) || undefined,
|
|
300
|
+
controlBaseURL:
|
|
301
|
+
value.controlBaseURL === undefined
|
|
302
|
+
? base.controlBaseURL
|
|
303
|
+
: asString(value.controlBaseURL, base.controlBaseURL ?? "", warnings, `${path}.controlBaseURL`) || undefined,
|
|
304
|
+
controlKeyPath:
|
|
305
|
+
value.controlKeyPath === undefined
|
|
306
|
+
? base.controlKeyPath
|
|
307
|
+
: asString(value.controlKeyPath, base.controlKeyPath ?? "", warnings, `${path}.controlKeyPath`) || undefined,
|
|
308
|
+
/**
|
|
309
|
+
* A user-supplied reading of their own account usage. Deliberately strict:
|
|
310
|
+
* an unusable baseline is dropped with a warning rather than half-applied,
|
|
311
|
+
* because a malformed baseline would silently corrupt every quota figure.
|
|
312
|
+
*/
|
|
313
|
+
baseline: (() => {
|
|
314
|
+
if (value.baseline === undefined) return base.baseline
|
|
315
|
+
if (!isRecord(value.baseline)) {
|
|
316
|
+
warnings.push(`${path}.baseline must be an object; ignoring it`)
|
|
317
|
+
return base.baseline
|
|
318
|
+
}
|
|
319
|
+
const at = timeValue(value.baseline.at)
|
|
320
|
+
if (at === undefined) {
|
|
321
|
+
warnings.push(`${path}.baseline.at must be a timestamp; ignoring the baseline`)
|
|
322
|
+
return base.baseline
|
|
323
|
+
}
|
|
324
|
+
const design = numberOr(value.baseline.designPlanUsedUsd)
|
|
325
|
+
const planModels = numberOr(value.baseline.planModelsUsedUsd)
|
|
326
|
+
const designPct = numberOr(value.baseline.designPlanUsedPct)
|
|
327
|
+
const planModelsPct = numberOr(value.baseline.planModelsUsedPct)
|
|
328
|
+
const pctOfRaw = value.baseline.pctOf
|
|
329
|
+
const pctOf = pctOfRaw === "weekly" || pctOfRaw === "monthly" ? pctOfRaw : undefined
|
|
330
|
+
if (pctOfRaw !== undefined && pctOf === undefined) {
|
|
331
|
+
warnings.push(`${path}.baseline.pctOf must be "weekly" or "monthly"; defaulting to weekly`)
|
|
332
|
+
}
|
|
333
|
+
const clampPct = (value: number | undefined) =>
|
|
334
|
+
value === undefined ? undefined : Math.min(100, Math.max(0, value))
|
|
335
|
+
if (design === undefined && planModels === undefined && designPct === undefined && planModelsPct === undefined) {
|
|
336
|
+
warnings.push(
|
|
337
|
+
`${path}.baseline needs a figure: designPlanUsedUsd, designPlanUsedPct, planModelsUsedUsd or planModelsUsedPct`,
|
|
338
|
+
)
|
|
339
|
+
return base.baseline
|
|
340
|
+
}
|
|
341
|
+
if (at > Date.now()) {
|
|
342
|
+
warnings.push(`${path}.baseline.at is in the future; ignoring the baseline`)
|
|
343
|
+
return base.baseline
|
|
344
|
+
}
|
|
345
|
+
const weeklyResetsAt = timeValue(value.baseline.weeklyResetsAt)
|
|
346
|
+
const monthlyResetsAt = timeValue(value.baseline.monthlyResetsAt)
|
|
347
|
+
const source = typeof value.baseline.source === "string" ? value.baseline.source.trim() : undefined
|
|
348
|
+
return {
|
|
349
|
+
at,
|
|
350
|
+
...(design !== undefined ? { designPlanUsedUsd: Math.max(0, design) } : {}),
|
|
351
|
+
...(planModels !== undefined ? { planModelsUsedUsd: Math.max(0, planModels) } : {}),
|
|
352
|
+
...(clampPct(designPct) !== undefined ? { designPlanUsedPct: clampPct(designPct)! } : {}),
|
|
353
|
+
...(clampPct(planModelsPct) !== undefined ? { planModelsUsedPct: clampPct(planModelsPct)! } : {}),
|
|
354
|
+
...(pctOf ? { pctOf } : {}),
|
|
355
|
+
...(weeklyResetsAt !== undefined ? { weeklyResetsAt } : {}),
|
|
356
|
+
...(monthlyResetsAt !== undefined ? { monthlyResetsAt } : {}),
|
|
357
|
+
...(source ? { source } : {}),
|
|
358
|
+
}
|
|
359
|
+
})(),
|
|
360
|
+
subscriptionOnly:
|
|
361
|
+
value.subscriptionOnly === undefined
|
|
362
|
+
? base.subscriptionOnly
|
|
363
|
+
: asBoolean(value.subscriptionOnly, base.subscriptionOnly ?? true, warnings, `${path}.subscriptionOnly`),
|
|
364
|
+
creditsPerUsd:
|
|
365
|
+
value.creditsPerUsd === undefined
|
|
366
|
+
? base.creditsPerUsd
|
|
367
|
+
: asNumber(value.creditsPerUsd, base.creditsPerUsd ?? 10_000, warnings, `${path}.creditsPerUsd`, 1, 10_000_000),
|
|
368
|
+
uncertaintyMarginPct:
|
|
369
|
+
value.uncertaintyMarginPct === undefined
|
|
370
|
+
? base.uncertaintyMarginPct
|
|
371
|
+
: asNumber(
|
|
372
|
+
value.uncertaintyMarginPct,
|
|
373
|
+
base.uncertaintyMarginPct ?? 20,
|
|
374
|
+
warnings,
|
|
375
|
+
`${path}.uncertaintyMarginPct`,
|
|
376
|
+
0,
|
|
377
|
+
500,
|
|
378
|
+
),
|
|
379
|
+
unknownQuotaPolicy:
|
|
380
|
+
value.unknownQuotaPolicy === undefined
|
|
381
|
+
? base.unknownQuotaPolicy
|
|
382
|
+
: asEnum(
|
|
383
|
+
value.unknownQuotaPolicy,
|
|
384
|
+
["route", "block", "ask"] as const,
|
|
385
|
+
base.unknownQuotaPolicy ?? "route",
|
|
386
|
+
warnings,
|
|
387
|
+
`${path}.unknownQuotaPolicy`,
|
|
388
|
+
),
|
|
389
|
+
multiplierOverrides:
|
|
390
|
+
value.multiplierOverrides === undefined
|
|
391
|
+
? base.multiplierOverrides
|
|
392
|
+
: normalizeMultiplierOverrides(value.multiplierOverrides, base.multiplierOverrides, warnings, `${path}.multiplierOverrides`),
|
|
393
|
+
providerScopedQuota:
|
|
394
|
+
value.providerScopedQuota === undefined
|
|
395
|
+
? base.providerScopedQuota
|
|
396
|
+
: asBoolean(value.providerScopedQuota, base.providerScopedQuota ?? false, warnings, `${path}.providerScopedQuota`),
|
|
397
|
+
ignoreFiveHourWindow:
|
|
398
|
+
value.ignoreFiveHourWindow === undefined
|
|
399
|
+
? base.ignoreFiveHourWindow
|
|
400
|
+
: asBoolean(value.ignoreFiveHourWindow, base.ignoreFiveHourWindow ?? false, warnings, `${path}.ignoreFiveHourWindow`),
|
|
401
|
+
ignoreWeeklyWindow:
|
|
402
|
+
value.ignoreWeeklyWindow === undefined
|
|
403
|
+
? base.ignoreWeeklyWindow
|
|
404
|
+
: asBoolean(value.ignoreWeeklyWindow, base.ignoreWeeklyWindow ?? false, warnings, `${path}.ignoreWeeklyWindow`),
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const finiteNonNegative = (value: unknown): number | undefined =>
|
|
409
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined
|
|
410
|
+
|
|
411
|
+
const normalizeMultiplierOverrides = (
|
|
412
|
+
value: unknown,
|
|
413
|
+
base: Readonly<Record<string, number>> | undefined,
|
|
414
|
+
warnings: string[],
|
|
415
|
+
path: string,
|
|
416
|
+
): Readonly<Record<string, number>> | undefined => {
|
|
417
|
+
if (value === undefined) return base
|
|
418
|
+
if (!isRecord(value)) {
|
|
419
|
+
warnings.push(`${path} must be an object; using the previous value`)
|
|
420
|
+
return base
|
|
421
|
+
}
|
|
422
|
+
const out: Record<string, number> = { ...(base ?? {}) }
|
|
423
|
+
for (const [modelID, multiplier] of Object.entries(value)) {
|
|
424
|
+
const parsed = finiteNonNegative(multiplier)
|
|
425
|
+
if (parsed === undefined || parsed <= 0) {
|
|
426
|
+
warnings.push(`${path}.${modelID} must be a positive number; the entry was ignored`)
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
out[modelID] = parsed
|
|
430
|
+
}
|
|
431
|
+
return out
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const normalizeCostTiers = (value: unknown, index: number, warnings: string[]): CostTier[] => {
|
|
435
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
436
|
+
warnings.push(`models.definitions[${index}].cost must contain at least one tier; definition ignored`)
|
|
437
|
+
return []
|
|
438
|
+
}
|
|
439
|
+
const output: CostTier[] = []
|
|
440
|
+
value.forEach((item, tierIndex) => {
|
|
441
|
+
if (!isRecord(item)) {
|
|
442
|
+
warnings.push(`models.definitions[${index}].cost[${tierIndex}] must be an object; ignored`)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
const input = finiteNonNegative(item.input)
|
|
446
|
+
const outputCost = finiteNonNegative(item.output)
|
|
447
|
+
if (input === undefined || outputCost === undefined) {
|
|
448
|
+
warnings.push(
|
|
449
|
+
`models.definitions[${index}].cost[${tierIndex}] needs non-negative finite input and output; ignored`,
|
|
450
|
+
)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const cache = isRecord(item.cache) ? item.cache : undefined
|
|
454
|
+
output.push({
|
|
455
|
+
input,
|
|
456
|
+
output: outputCost,
|
|
457
|
+
cache: {
|
|
458
|
+
read: finiteNonNegative(cache?.read) ?? 0,
|
|
459
|
+
write: finiteNonNegative(cache?.write) ?? 0,
|
|
460
|
+
},
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
return output
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const validateDefinitions = (value: unknown, warnings: string[]): ModelDefinition[] => {
|
|
467
|
+
if (value === undefined) return []
|
|
468
|
+
if (!Array.isArray(value)) {
|
|
469
|
+
warnings.push("models.definitions must be an array; ignored")
|
|
470
|
+
return []
|
|
471
|
+
}
|
|
472
|
+
const output: ModelDefinition[] = []
|
|
473
|
+
value.forEach((entry, index) => {
|
|
474
|
+
if (!isRecord(entry)) {
|
|
475
|
+
warnings.push(`models.definitions[${index}] must be an object; ignored`)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
const key = typeof entry.key === "string" ? entry.key.trim() : ""
|
|
479
|
+
const providerID = typeof entry.providerID === "string" ? entry.providerID.trim() : ""
|
|
480
|
+
const modelID = typeof entry.modelID === "string" ? entry.modelID.trim() : ""
|
|
481
|
+
const name = typeof entry.name === "string" ? entry.name.trim() : modelID
|
|
482
|
+
const tier = entry.tier === 1 || entry.tier === 2 || entry.tier === 3 ? entry.tier : 0
|
|
483
|
+
if (!key || !providerID || !modelID || !tier) {
|
|
484
|
+
warnings.push(`models.definitions[${index}] needs key, providerID, modelID, and tier 1-3; ignored`)
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
const input = Array.isArray(entry.input) && entry.input.every((item) => typeof item === "string") ? entry.input : ["text"]
|
|
488
|
+
const cost = normalizeCostTiers(entry.cost, index, warnings)
|
|
489
|
+
if (cost.length === 0) return
|
|
490
|
+
output.push({
|
|
491
|
+
key,
|
|
492
|
+
providerID,
|
|
493
|
+
modelID,
|
|
494
|
+
name,
|
|
495
|
+
context: Math.max(1, Number(entry.context) || 0),
|
|
496
|
+
maxOutput: Math.max(1, Number(entry.maxOutput) || 0),
|
|
497
|
+
input,
|
|
498
|
+
cost,
|
|
499
|
+
intelligence: Math.max(0, Number(entry.intelligence) || 0),
|
|
500
|
+
design: Math.max(0, Number(entry.design) || 0),
|
|
501
|
+
premium: entry.premium === true,
|
|
502
|
+
training: entry.training === true,
|
|
503
|
+
free: entry.free === true,
|
|
504
|
+
tier,
|
|
505
|
+
approved: entry.approved !== false,
|
|
506
|
+
monthlyAllowanceUsd: Math.max(0, Number(entry.monthlyAllowanceUsd) || 0),
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
return output
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const validateModels = (value: unknown, base: ModelSettings, warnings: string[]): ModelSettings => {
|
|
513
|
+
if (!isRecord(value)) {
|
|
514
|
+
warnings.push("models must be an object; using defaults")
|
|
515
|
+
return base
|
|
516
|
+
}
|
|
517
|
+
const overrides: Record<string, 1 | 2 | 3> = { ...base.tierOverrides }
|
|
518
|
+
if (value.tierOverrides !== undefined) {
|
|
519
|
+
if (!isRecord(value.tierOverrides)) {
|
|
520
|
+
warnings.push("models.tierOverrides must be an object; using the previous value")
|
|
521
|
+
} else {
|
|
522
|
+
for (const [key, tier] of Object.entries(value.tierOverrides)) {
|
|
523
|
+
if (tier === 1 || tier === 2 || tier === 3) overrides[key] = tier
|
|
524
|
+
else warnings.push(`models.tierOverrides.${key} must be 1, 2, or 3; ignored`)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
approved: asStringArray(value.approved, base.approved as string[], warnings, "models.approved"),
|
|
530
|
+
excluded: asStringArray(value.excluded, base.excluded as string[], warnings, "models.excluded"),
|
|
531
|
+
tierOverrides: overrides,
|
|
532
|
+
definitions: validateDefinitions(value.definitions, warnings),
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Validate a partial settings object and merge it over a base. Unknown keys are
|
|
538
|
+
* ignored with a warning; invalid values never crash plugin startup.
|
|
539
|
+
*/
|
|
540
|
+
export const validateSettings = (input: unknown, base: RouterSettings = DEFAULT_SETTINGS): SettingsLoadResult => {
|
|
541
|
+
const warnings: string[] = []
|
|
542
|
+
if (!isRecord(input)) {
|
|
543
|
+
return { settings: base, warnings: ["settings must be a JSON object"], sources: [], explicitProviderEnable: new Set() }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const providers: Record<string, ProviderSettings> = { ...base.providers }
|
|
547
|
+
if (input.providers !== undefined) {
|
|
548
|
+
if (!isRecord(input.providers)) {
|
|
549
|
+
warnings.push("providers must be an object; using defaults")
|
|
550
|
+
} else {
|
|
551
|
+
for (const [id, value] of Object.entries(input.providers)) {
|
|
552
|
+
providers[id] = validateProvider(value, providers[id] ?? providerDefaults("custom"), warnings, `providers.${id}`)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const privacyInput = isRecord(input.privacy) ? input.privacy : {}
|
|
558
|
+
const privacy: PrivacySettings = {
|
|
559
|
+
allowTrainingModels: asBoolean(
|
|
560
|
+
privacyInput.allowTrainingModels,
|
|
561
|
+
base.privacy.allowTrainingModels,
|
|
562
|
+
warnings,
|
|
563
|
+
"privacy.allowTrainingModels",
|
|
564
|
+
),
|
|
565
|
+
allowFreeModels: asBoolean(privacyInput.allowFreeModels, base.privacy.allowFreeModels, warnings, "privacy.allowFreeModels"),
|
|
566
|
+
requireZdr: asBoolean(privacyInput.requireZdr, base.privacy.requireZdr, warnings, "privacy.requireZdr"),
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const premiumInput = isRecord(input.premium) ? input.premium : {}
|
|
570
|
+
const premium: PremiumSettings = {
|
|
571
|
+
mode: asEnum(premiumInput.mode, ["off", "ask", "allowed"] as const, base.premium.mode, warnings, "premium.mode"),
|
|
572
|
+
monthlyBudgetUsd: asNumber(
|
|
573
|
+
premiumInput.monthlyBudgetUsd,
|
|
574
|
+
base.premium.monthlyBudgetUsd,
|
|
575
|
+
warnings,
|
|
576
|
+
"premium.monthlyBudgetUsd",
|
|
577
|
+
0,
|
|
578
|
+
100_000,
|
|
579
|
+
),
|
|
580
|
+
oneShotApproval: asBoolean(premiumInput.oneShotApproval, base.premium.oneShotApproval, warnings, "premium.oneShotApproval"),
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const routingInput = isRecord(input.routing) ? input.routing : {}
|
|
584
|
+
const routing = {
|
|
585
|
+
intelligenceFloor: asNumber(
|
|
586
|
+
routingInput.intelligenceFloor,
|
|
587
|
+
base.routing.intelligenceFloor,
|
|
588
|
+
warnings,
|
|
589
|
+
"routing.intelligenceFloor",
|
|
590
|
+
0,
|
|
591
|
+
100,
|
|
592
|
+
),
|
|
593
|
+
designFloor: asNumber(routingInput.designFloor, base.routing.designFloor, warnings, "routing.designFloor", 0, 100),
|
|
594
|
+
sophisticatedDesignFloor: asNumber(
|
|
595
|
+
routingInput.sophisticatedDesignFloor,
|
|
596
|
+
base.routing.sophisticatedDesignFloor,
|
|
597
|
+
warnings,
|
|
598
|
+
"routing.sophisticatedDesignFloor",
|
|
599
|
+
0,
|
|
600
|
+
100,
|
|
601
|
+
),
|
|
602
|
+
switchHysteresisPct: asNumber(
|
|
603
|
+
routingInput.switchHysteresisPct,
|
|
604
|
+
base.routing.switchHysteresisPct,
|
|
605
|
+
warnings,
|
|
606
|
+
"routing.switchHysteresisPct",
|
|
607
|
+
0,
|
|
608
|
+
100,
|
|
609
|
+
),
|
|
610
|
+
largeContextTokens: asNumber(
|
|
611
|
+
routingInput.largeContextTokens,
|
|
612
|
+
base.routing.largeContextTokens,
|
|
613
|
+
warnings,
|
|
614
|
+
"routing.largeContextTokens",
|
|
615
|
+
10_000,
|
|
616
|
+
5_000_000,
|
|
617
|
+
),
|
|
618
|
+
largeContextHysteresisPct: asNumber(
|
|
619
|
+
routingInput.largeContextHysteresisPct,
|
|
620
|
+
base.routing.largeContextHysteresisPct,
|
|
621
|
+
warnings,
|
|
622
|
+
"routing.largeContextHysteresisPct",
|
|
623
|
+
0,
|
|
624
|
+
100,
|
|
625
|
+
),
|
|
626
|
+
maxEscalationsPerTask: asNumber(
|
|
627
|
+
routingInput.maxEscalationsPerTask,
|
|
628
|
+
base.routing.maxEscalationsPerTask,
|
|
629
|
+
warnings,
|
|
630
|
+
"routing.maxEscalationsPerTask",
|
|
631
|
+
0,
|
|
632
|
+
5,
|
|
633
|
+
),
|
|
634
|
+
preferSameProvider: asBoolean(
|
|
635
|
+
routingInput.preferSameProvider,
|
|
636
|
+
base.routing.preferSameProvider,
|
|
637
|
+
warnings,
|
|
638
|
+
"routing.preferSameProvider",
|
|
639
|
+
),
|
|
640
|
+
failClosed: asBoolean(routingInput.failClosed, base.routing.failClosed, warnings, "routing.failClosed"),
|
|
641
|
+
protectEscalationPool: asBoolean(
|
|
642
|
+
routingInput.protectEscalationPool,
|
|
643
|
+
base.routing.protectEscalationPool,
|
|
644
|
+
warnings,
|
|
645
|
+
"routing.protectEscalationPool",
|
|
646
|
+
),
|
|
647
|
+
longContextFloorTokens: asNumber(
|
|
648
|
+
routingInput.longContextFloorTokens,
|
|
649
|
+
base.routing.longContextFloorTokens,
|
|
650
|
+
warnings,
|
|
651
|
+
"routing.longContextFloorTokens",
|
|
652
|
+
10_000,
|
|
653
|
+
10_000_000,
|
|
654
|
+
),
|
|
655
|
+
// Opt-in. Defaults to off so an upgrade cannot change routing behavior on
|
|
656
|
+
// its own; the owner turns it on after seeing what it would pick.
|
|
657
|
+
calibrateFloor: asBoolean(
|
|
658
|
+
routingInput.calibrateFloor,
|
|
659
|
+
base.routing.calibrateFloor ?? false,
|
|
660
|
+
warnings,
|
|
661
|
+
"routing.calibrateFloor",
|
|
662
|
+
),
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const quotaInput = isRecord(input.quota) ? input.quota : {}
|
|
666
|
+
const quota = {
|
|
667
|
+
probeOpenCodeGo: asBoolean(quotaInput.probeOpenCodeGo, base.quota.probeOpenCodeGo, warnings, "quota.probeOpenCodeGo"),
|
|
668
|
+
probeCommandCode: asBoolean(quotaInput.probeCommandCode, base.quota.probeCommandCode, warnings, "quota.probeCommandCode"),
|
|
669
|
+
probeOpenDesign: asBoolean(quotaInput.probeOpenDesign, base.quota.probeOpenDesign, warnings, "quota.probeOpenDesign"),
|
|
670
|
+
probeIntervalMinutes: asNumber(
|
|
671
|
+
quotaInput.probeIntervalMinutes,
|
|
672
|
+
base.quota.probeIntervalMinutes,
|
|
673
|
+
warnings,
|
|
674
|
+
"quota.probeIntervalMinutes",
|
|
675
|
+
0,
|
|
676
|
+
1440,
|
|
677
|
+
),
|
|
678
|
+
bootstrapSessions: asNumber(
|
|
679
|
+
quotaInput.bootstrapSessions,
|
|
680
|
+
base.quota.bootstrapSessions,
|
|
681
|
+
warnings,
|
|
682
|
+
"quota.bootstrapSessions",
|
|
683
|
+
0,
|
|
684
|
+
500,
|
|
685
|
+
),
|
|
686
|
+
bootstrapDays: asNumber(quotaInput.bootstrapDays, base.quota.bootstrapDays, warnings, "quota.bootstrapDays", 0, 365),
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const healthInput = isRecord(input.contextHealth) ? input.contextHealth : {}
|
|
690
|
+
const contextHealth = {
|
|
691
|
+
enabled: asBoolean(healthInput.enabled, base.contextHealth.enabled, warnings, "contextHealth.enabled"),
|
|
692
|
+
warnAtTokens: asNumber(
|
|
693
|
+
healthInput.warnAtTokens,
|
|
694
|
+
base.contextHealth.warnAtTokens,
|
|
695
|
+
warnings,
|
|
696
|
+
"contextHealth.warnAtTokens",
|
|
697
|
+
10_000,
|
|
698
|
+
10_000_000,
|
|
699
|
+
),
|
|
700
|
+
recommendAtTokens: asNumber(
|
|
701
|
+
healthInput.recommendAtTokens,
|
|
702
|
+
base.contextHealth.recommendAtTokens,
|
|
703
|
+
warnings,
|
|
704
|
+
"contextHealth.recommendAtTokens",
|
|
705
|
+
10_000,
|
|
706
|
+
10_000_000,
|
|
707
|
+
),
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const uiInput = isRecord(input.ui) ? input.ui : {}
|
|
711
|
+
const compactToggle = asBoolean(
|
|
712
|
+
uiInput.compactMode ?? uiInput.compact ?? input.compactMode ?? input.compact,
|
|
713
|
+
base.ui.compactMode || base.ui.compact,
|
|
714
|
+
warnings,
|
|
715
|
+
"ui.compactMode",
|
|
716
|
+
)
|
|
717
|
+
const ui = {
|
|
718
|
+
titleIndicator: asBoolean(uiInput.titleIndicator, base.ui.titleIndicator, warnings, "ui.titleIndicator"),
|
|
719
|
+
notifications: asBoolean(uiInput.notifications, base.ui.notifications, warnings, "ui.notifications"),
|
|
720
|
+
compactMode: compactToggle,
|
|
721
|
+
compact: compactToggle,
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const loggingInput = isRecord(input.logging) ? input.logging : {}
|
|
725
|
+
const logging = {
|
|
726
|
+
storePromptText: asBoolean(loggingInput.storePromptText, base.logging.storePromptText, warnings, "logging.storePromptText"),
|
|
727
|
+
decisionLogSize: asNumber(
|
|
728
|
+
loggingInput.decisionLogSize,
|
|
729
|
+
base.logging.decisionLogSize,
|
|
730
|
+
warnings,
|
|
731
|
+
"logging.decisionLogSize",
|
|
732
|
+
10,
|
|
733
|
+
2000,
|
|
734
|
+
),
|
|
735
|
+
diagnostics: asBoolean(loggingInput.diagnostics, base.logging.diagnostics, warnings, "logging.diagnostics"),
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const contextInput = isRecord(input.contextManagement) ? input.contextManagement : {}
|
|
739
|
+
const contextManagement = {
|
|
740
|
+
mode: asEnum(
|
|
741
|
+
contextInput.mode,
|
|
742
|
+
["native", "conservative", "budget-optimized"] as const,
|
|
743
|
+
base.contextManagement.mode,
|
|
744
|
+
warnings,
|
|
745
|
+
"contextManagement.mode",
|
|
746
|
+
),
|
|
747
|
+
enabled: asBoolean(contextInput.enabled, base.contextManagement.enabled, warnings, "contextManagement.enabled"),
|
|
748
|
+
compactAtTokens: asNumber(
|
|
749
|
+
contextInput.compactAtTokens,
|
|
750
|
+
base.contextManagement.compactAtTokens,
|
|
751
|
+
warnings,
|
|
752
|
+
"contextManagement.compactAtTokens",
|
|
753
|
+
0,
|
|
754
|
+
10_000_000,
|
|
755
|
+
),
|
|
756
|
+
cooldownMinutes: asNumber(
|
|
757
|
+
contextInput.cooldownMinutes,
|
|
758
|
+
base.contextManagement.cooldownMinutes,
|
|
759
|
+
warnings,
|
|
760
|
+
"contextManagement.cooldownMinutes",
|
|
761
|
+
1,
|
|
762
|
+
1440,
|
|
763
|
+
),
|
|
764
|
+
allowEmergencyCompaction: asBoolean(
|
|
765
|
+
contextInput.allowEmergencyCompaction,
|
|
766
|
+
base.contextManagement.allowEmergencyCompaction,
|
|
767
|
+
warnings,
|
|
768
|
+
"contextManagement.allowEmergencyCompaction",
|
|
769
|
+
),
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const speedInput = isRecord(input.speed) ? input.speed : {}
|
|
773
|
+
const speed = {
|
|
774
|
+
enabled: asBoolean(speedInput.enabled, base.speed.enabled, warnings, "speed.enabled"),
|
|
775
|
+
weightPct: asNumber(speedInput.weightPct, base.speed.weightPct, warnings, "speed.weightPct", 0, 100),
|
|
776
|
+
minSamples: asNumber(speedInput.minSamples, base.speed.minSamples, warnings, "speed.minSamples", 1, 500),
|
|
777
|
+
slowRouteMultiple: asNumber(
|
|
778
|
+
speedInput.slowRouteMultiple,
|
|
779
|
+
base.speed.slowRouteMultiple,
|
|
780
|
+
warnings,
|
|
781
|
+
"speed.slowRouteMultiple",
|
|
782
|
+
1,
|
|
783
|
+
20,
|
|
784
|
+
),
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (contextManagement.mode === "budget-optimized" && contextManagement.compactAtTokens <= 0) {
|
|
788
|
+
warnings.push(
|
|
789
|
+
"contextManagement.mode is budget-optimized but compactAtTokens is 0; only the cheapest route's verified window will trigger compaction",
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const offline = validateOffline(input.offline, base.offline, warnings)
|
|
794
|
+
|
|
795
|
+
const settings: RouterSettings = {
|
|
796
|
+
enabled: asBoolean(input.enabled, base.enabled, warnings, "enabled"),
|
|
797
|
+
mode: asEnum(input.mode, ["efficiency", "balanced", "premium"] as const, base.mode, warnings, "mode"),
|
|
798
|
+
dryRun: asBoolean(input.dryRun, base.dryRun, warnings, "dryRun"),
|
|
799
|
+
compactMode: compactToggle,
|
|
800
|
+
compact: compactToggle,
|
|
801
|
+
providers,
|
|
802
|
+
privacy,
|
|
803
|
+
premium,
|
|
804
|
+
routing,
|
|
805
|
+
quota,
|
|
806
|
+
contextHealth,
|
|
807
|
+
contextManagement,
|
|
808
|
+
speed,
|
|
809
|
+
ui,
|
|
810
|
+
logging,
|
|
811
|
+
models: validateModels(input.models, base.models, warnings),
|
|
812
|
+
offline,
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (settings.premium.mode === "allowed" && settings.premium.monthlyBudgetUsd <= 0) {
|
|
816
|
+
warnings.push("premium.mode is allowed but premium.monthlyBudgetUsd is 0; premium remains unavailable")
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return { settings, warnings, sources: [], explicitProviderEnable: new Set() }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Validate the offline block.
|
|
824
|
+
*
|
|
825
|
+
* Kept separate from the main validator because offline mode has its own
|
|
826
|
+
* failure mode: a misconfigured endpoint or model store must be a loud warning
|
|
827
|
+
* rather than a silent default, since a silent default here is a model loaded
|
|
828
|
+
* onto the wrong disk or a session pointed at a hosted URL.
|
|
829
|
+
*/
|
|
830
|
+
const validateOffline = (
|
|
831
|
+
input: unknown,
|
|
832
|
+
base: OfflineSettings | undefined,
|
|
833
|
+
warnings: string[],
|
|
834
|
+
): OfflineSettings => {
|
|
835
|
+
const resolved = base ?? DEFAULT_OFFLINE_SETTINGS
|
|
836
|
+
if (input === undefined) return resolved
|
|
837
|
+
if (!isRecord(input)) {
|
|
838
|
+
warnings.push("offline must be an object; using defaults")
|
|
839
|
+
return resolved
|
|
840
|
+
}
|
|
841
|
+
const modelInput = isRecord(input.model) ? input.model : {}
|
|
842
|
+
const modelBase = resolved.model
|
|
843
|
+
|
|
844
|
+
const baseURL = typeof modelInput.baseURL === "string" && modelInput.baseURL.trim()
|
|
845
|
+
? modelInput.baseURL.trim()
|
|
846
|
+
: modelBase.baseURL
|
|
847
|
+
if (!isLoopbackURL(baseURL)) {
|
|
848
|
+
// A routable address here would expose prompts and model control to the
|
|
849
|
+
// local network, and would also mean the "local" endpoint might not be
|
|
850
|
+
// local at all. Refuse it and fall back to loopback.
|
|
851
|
+
warnings.push(
|
|
852
|
+
`offline.model.baseURL ${baseURL} is not a loopback address; using ${modelBase.baseURL}. Offline mode must not reach a routable endpoint.`,
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const modelName = typeof modelInput.model === "string" && modelInput.model.trim()
|
|
857
|
+
? modelInput.model.trim()
|
|
858
|
+
: modelBase.model
|
|
859
|
+
|
|
860
|
+
const contextTokens = asNumber(
|
|
861
|
+
modelInput.contextTokens,
|
|
862
|
+
modelBase.contextTokens,
|
|
863
|
+
warnings,
|
|
864
|
+
"offline.model.contextTokens",
|
|
865
|
+
1_024,
|
|
866
|
+
131_072,
|
|
867
|
+
)
|
|
868
|
+
if (contextTokens < MINIMUM_OFFLINE_CONTEXT) {
|
|
869
|
+
warnings.push(
|
|
870
|
+
`offline.model.contextTokens is ${contextTokens}, below the ${MINIMUM_OFFLINE_CONTEXT} needed for a screenshot-driven edit`,
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const storeInput = input.modelStorePath
|
|
875
|
+
let modelStorePath: string | undefined
|
|
876
|
+
if (typeof storeInput === "string" && storeInput.trim()) {
|
|
877
|
+
modelStorePath = storeInput.trim()
|
|
878
|
+
} else if (storeInput !== undefined) {
|
|
879
|
+
warnings.push("offline.modelStorePath must be a non-empty string; ignored")
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
enabled: asBoolean(input.enabled, resolved.enabled, warnings, "offline.enabled"),
|
|
884
|
+
...(modelStorePath ? { modelStorePath } : {}),
|
|
885
|
+
model: {
|
|
886
|
+
model: modelName,
|
|
887
|
+
baseURL: isLoopbackURL(baseURL) ? baseURL : modelBase.baseURL,
|
|
888
|
+
contextTokens,
|
|
889
|
+
maxOutputTokens: asNumber(
|
|
890
|
+
modelInput.maxOutputTokens,
|
|
891
|
+
modelBase.maxOutputTokens,
|
|
892
|
+
warnings,
|
|
893
|
+
"offline.model.maxOutputTokens",
|
|
894
|
+
128,
|
|
895
|
+
32_768,
|
|
896
|
+
),
|
|
897
|
+
concurrency: asNumber(
|
|
898
|
+
modelInput.concurrency,
|
|
899
|
+
modelBase.concurrency,
|
|
900
|
+
warnings,
|
|
901
|
+
"offline.model.concurrency",
|
|
902
|
+
1,
|
|
903
|
+
4,
|
|
904
|
+
),
|
|
905
|
+
keepAliveMinutes: asNumber(
|
|
906
|
+
modelInput.keepAliveMinutes,
|
|
907
|
+
modelBase.keepAliveMinutes,
|
|
908
|
+
warnings,
|
|
909
|
+
"offline.model.keepAliveMinutes",
|
|
910
|
+
0,
|
|
911
|
+
120,
|
|
912
|
+
),
|
|
913
|
+
vision: asBoolean(modelInput.vision, modelBase.vision, warnings, "offline.model.vision"),
|
|
914
|
+
tools: asBoolean(modelInput.tools, modelBase.tools, warnings, "offline.model.tools"),
|
|
915
|
+
},
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Project files may tighten privacy or spending, but they cannot loosen global
|
|
921
|
+
* policy. This protects a work repository from enabling training models or
|
|
922
|
+
* premium spend through a checked-in config file.
|
|
923
|
+
*/
|
|
924
|
+
export const applyProjectGuardrails = (
|
|
925
|
+
globalSettings: RouterSettings,
|
|
926
|
+
projectInput: unknown,
|
|
927
|
+
warnings: string[],
|
|
928
|
+
): RouterSettings => {
|
|
929
|
+
const candidate = validateSettings(projectInput, globalSettings).settings
|
|
930
|
+
const tightened: RouterSettings = {
|
|
931
|
+
...candidate,
|
|
932
|
+
enabled: globalSettings.enabled && candidate.enabled,
|
|
933
|
+
privacy: {
|
|
934
|
+
allowTrainingModels: globalSettings.privacy.allowTrainingModels && candidate.privacy.allowTrainingModels,
|
|
935
|
+
allowFreeModels: globalSettings.privacy.allowFreeModels && candidate.privacy.allowFreeModels,
|
|
936
|
+
requireZdr: globalSettings.privacy.requireZdr || candidate.privacy.requireZdr,
|
|
937
|
+
},
|
|
938
|
+
premium: {
|
|
939
|
+
...candidate.premium,
|
|
940
|
+
mode:
|
|
941
|
+
globalSettings.premium.mode === "off" || candidate.premium.mode === "off"
|
|
942
|
+
? "off"
|
|
943
|
+
: globalSettings.premium.mode === "ask" || candidate.premium.mode === "ask"
|
|
944
|
+
? "ask"
|
|
945
|
+
: "allowed",
|
|
946
|
+
monthlyBudgetUsd: Math.min(globalSettings.premium.monthlyBudgetUsd, candidate.premium.monthlyBudgetUsd),
|
|
947
|
+
},
|
|
948
|
+
providers: Object.fromEntries(
|
|
949
|
+
Object.entries(candidate.providers).map(([id, provider]) => {
|
|
950
|
+
const globalProvider = globalSettings.providers[id]
|
|
951
|
+
if (!globalProvider) return [id, { ...provider, enabled: false, allowExtraUsage: false }]
|
|
952
|
+
return [
|
|
953
|
+
id,
|
|
954
|
+
{
|
|
955
|
+
...provider,
|
|
956
|
+
enabled: globalProvider.enabled && provider.enabled,
|
|
957
|
+
allowExtraUsage: globalProvider.allowExtraUsage && provider.allowExtraUsage,
|
|
958
|
+
},
|
|
959
|
+
]
|
|
960
|
+
}),
|
|
961
|
+
),
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (candidate.privacy.allowTrainingModels && !globalSettings.privacy.allowTrainingModels) {
|
|
965
|
+
warnings.push("Project config tried to enable training models; global privacy setting kept it disabled")
|
|
966
|
+
}
|
|
967
|
+
if (candidate.premium.mode === "allowed" && globalSettings.premium.mode !== "allowed") {
|
|
968
|
+
warnings.push("Project config tried to enable automatic premium routing; global setting kept it restricted")
|
|
969
|
+
}
|
|
970
|
+
return tightened
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const readJson = (path: string, warnings: string[]): unknown | undefined => {
|
|
974
|
+
if (!existsSync(path)) return undefined
|
|
975
|
+
try {
|
|
976
|
+
return JSON.parse(readFileSync(path, "utf8")) as unknown
|
|
977
|
+
} catch (error) {
|
|
978
|
+
warnings.push(`Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
979
|
+
return undefined
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export interface LoadSettingsInput {
|
|
984
|
+
options?: unknown
|
|
985
|
+
directory?: string
|
|
986
|
+
home?: string
|
|
987
|
+
projectDirectory?: string
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export const loadSettings = (input: LoadSettingsInput = {}): SettingsLoadResult => {
|
|
991
|
+
const warnings: string[] = []
|
|
992
|
+
const sources: string[] = []
|
|
993
|
+
const home = input.home ?? homedir()
|
|
994
|
+
const globalPath = join(home, ".config", "opencode", "smart-router.json")
|
|
995
|
+
|
|
996
|
+
let settings = DEFAULT_SETTINGS
|
|
997
|
+
const explicit = new Set<string>()
|
|
998
|
+
|
|
999
|
+
if (input.options !== undefined) {
|
|
1000
|
+
const validated = validateSettings(input.options, settings)
|
|
1001
|
+
settings = validated.settings
|
|
1002
|
+
// Validation warnings used to be thrown away here, so a malformed option was
|
|
1003
|
+
// repaired silently and the user never learned why their setting did nothing.
|
|
1004
|
+
warnings.push(...validated.warnings)
|
|
1005
|
+
sources.push("plugin options")
|
|
1006
|
+
for (const id of explicitProviderEnable(input.options)) explicit.add(id)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const globalInput = readJson(globalPath, warnings)
|
|
1010
|
+
if (globalInput !== undefined) {
|
|
1011
|
+
const validated = validateSettings(globalInput, settings)
|
|
1012
|
+
settings = validated.settings
|
|
1013
|
+
warnings.push(...validated.warnings)
|
|
1014
|
+
sources.push(globalPath)
|
|
1015
|
+
for (const id of explicitProviderEnable(globalInput)) explicit.add(id)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const projectDirectory = input.projectDirectory ?? input.directory
|
|
1019
|
+
if (projectDirectory) {
|
|
1020
|
+
const projectPath = join(projectDirectory, ".opencode", "smart-router.json")
|
|
1021
|
+
const projectInput = readJson(projectPath, warnings)
|
|
1022
|
+
if (projectInput !== undefined) {
|
|
1023
|
+
settings = applyProjectGuardrails(settings, projectInput, warnings)
|
|
1024
|
+
sources.push(projectPath)
|
|
1025
|
+
for (const id of explicitProviderEnable(projectInput)) explicit.add(id)
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return { settings, warnings, sources, explicitProviderEnable: explicit }
|
|
1030
|
+
}
|