@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. 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
+ }