@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1

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