switchback-core 2026.10.14 → 2026.10.16

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/src/config.ts CHANGED
@@ -47,9 +47,9 @@ import { join, resolve } from "node:path";
47
47
  import { parse as parseYaml } from "yaml";
48
48
  import { IDLE_RESET_OPTIONS } from "./idle.ts";
49
49
  import type {
50
+ DecisionModelConfig,
50
51
  DecisionModelEntry,
51
- JevConfig,
52
- JevRef,
52
+ DecisionModelId,
53
53
  ModelId,
54
54
  ResolvedSwitchbackConfig,
55
55
  ResolvedSwitchbackFileConfig,
@@ -62,9 +62,10 @@ const PROVIDER = "switchback";
62
62
  const VIRTUAL_ID = "auto";
63
63
  const CONFIG_BASENAME = "switchback";
64
64
 
65
- /** Classifier wire APIs switchback can drive from a direct endpoint. */
66
- export const LOCAL_CLASSIFIER_APIS: readonly string[] = ["typesafe-system-one"];
67
- const SUPPORTED_LOCAL_CLASSIFIER_APIS = LOCAL_CLASSIFIER_APIS;
65
+ /** Recognised keys, per level. Anything else is reported as a warning, not an error. */
66
+ const FILE_KEYS = ["models", "decisionModels", "debug"] as const;
67
+ const MODEL_KEYS = ["id", "name", "fallbacks", "decisionModel", "idleReset"] as const;
68
+ const DECISION_MODEL_KEYS = ["baseUrl", "apiKey"] as const;
68
69
 
69
70
  /**
70
71
  * Built-in default used when no user config is present.
@@ -111,9 +112,15 @@ export function piConfigDir(): string {
111
112
  * `~/.config/opencode` or `~/.pi/agent`) are superposed on top of.
112
113
  */
113
114
  export function switchbackConfigDir(): string {
114
- return join(homedir(), ".config", "switchback");
115
+ return process.env[SWITCHBACK_CONFIG_DIR_ENV] ?? join(homedir(), ".config", "switchback");
115
116
  }
116
117
 
118
+ /**
119
+ * Env override naming the neutral config directory outright. Lets a host or a
120
+ * test pin the basis layer without touching the user's real `~/.config/switchback`.
121
+ */
122
+ export const SWITCHBACK_CONFIG_DIR_ENV = "SWITCHBACK_CONFIG_DIR";
123
+
117
124
  /** Env override naming the shared switchback state directory outright. */
118
125
  export const SWITCHBACK_DIR_ENV = "SWITCHBACK_DIR";
119
126
 
@@ -178,101 +185,158 @@ function parseModelId(value: unknown, where: string): ModelId {
178
185
  return value;
179
186
  }
180
187
 
181
- function parseJev(value: unknown, where: string): JevRef | undefined {
182
- if (value === undefined) return undefined;
183
- if (!isRecord(value)) throw new ConfigError(`${where}.jev must be an object`, where);
184
- // Reference form: { decisionModel: <name> }. Must not mix with inline fields.
185
- const ref = value["decisionModel"];
186
- if (ref !== undefined) {
187
- if (typeof ref !== "string" || ref.length === 0) {
188
- throw new ConfigError(`${where}.jev.decisionModel must be a non-empty string`, where);
189
- }
190
- for (const key of Object.keys(value)) {
191
- if (key !== "decisionModel") {
192
- throw new ConfigError(
193
- `${where}.jev mixes decisionModel with "${key}" - use either the reference or the inline form`,
194
- where,
195
- );
196
- }
197
- }
198
- return { decisionModel: ref };
188
+ /** Keys the previous schema used. They warn with the replacement instead of a generic message. */
189
+ const RETIRED_KEYS: Record<string, string> = {
190
+ jev: "use decisionModel: <provider>/<model-id> instead",
191
+ api: "drop it - a decisionModels entry always speaks the SystemOne wire API",
192
+ };
193
+
194
+ /**
195
+ * Report keys this schema does not know. Stale or misspelled keys are warnings,
196
+ * not errors: the rest of the file still loads, and the message names the key so
197
+ * it can be fixed without reading the source.
198
+ */
199
+ function noteUnknownKeys(
200
+ value: Record<string, unknown>,
201
+ allowed: readonly string[],
202
+ where: string,
203
+ warnings: string[],
204
+ ): void {
205
+ for (const key of Object.keys(value)) {
206
+ if (allowed.includes(key)) continue;
207
+ const retired = RETIRED_KEYS[key];
208
+ warnings.push(
209
+ retired === undefined
210
+ ? `${where}.${key} is not a switchback config key and was ignored`
211
+ : `${where}.${key} is no longer supported; ${retired}`,
212
+ );
199
213
  }
200
- return parseJevInline(value, where);
201
214
  }
202
215
 
203
- /** Parse the inline JevConfig fields (shared by models[].jev and decisionModels[] entries). */
204
- function parseJevInline(value: Record<string, unknown>, where: string): JevConfig {
205
- const provider = value["provider"];
206
- const id = value["id"];
207
- if (typeof provider !== "string" || provider.length === 0) {
208
- throw new ConfigError(`${where}.jev.provider must be a non-empty string`, where);
209
- }
210
- if (typeof id !== "string" || id.length === 0) {
211
- throw new ConfigError(`${where}.jev.id must be a non-empty string`, where);
216
+ /** A decision-model address is "provider/model-id", the same shape as a fallback. */
217
+ function parseDecisionModelId(value: unknown, where: string): DecisionModelId {
218
+ return parseModelId(value, `${where}.decisionModel`);
219
+ }
220
+
221
+ /** Split an address on the first "/" - model ids may themselves contain ":" (e.g. tev1:0.8b). */
222
+ function splitAddress(address: DecisionModelId, where: string): { provider: string; id: string } {
223
+ const slash = address.indexOf("/");
224
+ if (slash <= 0 || slash === address.length - 1) {
225
+ throw new ConfigError(`${where} must be of the form "provider/model-id"`, where);
212
226
  }
227
+ return { provider: address.slice(0, slash), id: address.slice(slash + 1) };
228
+ }
213
229
 
214
- // Optional direct endpoint (a local System One server, e.g. Ollama v0.35+).
215
- // Presence of baseUrl switches switchback from "look the classifier up in
216
- // pi's catalog" to "register this endpoint ourselves".
217
- const rawBaseUrl = value["baseUrl"];
218
- let baseUrl: string | undefined;
219
- if (rawBaseUrl !== undefined) {
220
- if (typeof rawBaseUrl !== "string" || rawBaseUrl.length === 0) {
221
- throw new ConfigError(`${where}.jev.baseUrl must be a non-empty URL string`, where);
222
- }
223
- let parsed: URL;
224
- try {
225
- parsed = new URL(rawBaseUrl);
226
- } catch {
227
- throw new ConfigError(`${where}.jev.baseUrl is not a valid URL: ${rawBaseUrl}`, where);
228
- }
229
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
230
- throw new ConfigError(`${where}.jev.baseUrl must be http(s), got ${parsed.protocol}`, where);
231
- }
232
- baseUrl = rawBaseUrl;
230
+ /**
231
+ * Parse the `decisionModels:` map. Each key is a decision-model address; the
232
+ * value carries the connection details for a direct SystemOne endpoint.
233
+ */
234
+ function parseDecisionModels(
235
+ value: unknown,
236
+ path: string,
237
+ warnings: string[],
238
+ ): Record<DecisionModelId, DecisionModelEntry> | undefined {
239
+ if (value === undefined) return undefined;
240
+ if (Array.isArray(value)) {
241
+ warnings.push(
242
+ `decisionModels is now a map keyed by "provider/model-id" (e.g. typesafe/jev-latest: { baseUrl, apiKey }); the list form was ignored (${path})`,
243
+ );
244
+ return undefined;
233
245
  }
246
+ if (!isRecord(value)) throw new ConfigError('decisionModels must be an object keyed by "provider/model-id"', path);
247
+ const entries: Record<DecisionModelId, DecisionModelEntry> = {};
248
+ for (const [address, rawEntry] of Object.entries(value)) {
249
+ const where = `decisionModels.${address}`;
250
+ parseModelId(address, where);
251
+ if (!isRecord(rawEntry)) throw new ConfigError(`${where} must be an object with baseUrl and apiKey`, path);
252
+ noteUnknownKeys(rawEntry, DECISION_MODEL_KEYS, where, warnings);
253
+
254
+ const rawBaseUrl = rawEntry["baseUrl"];
255
+ let baseUrl: string | undefined;
256
+ if (rawBaseUrl !== undefined) {
257
+ if (typeof rawBaseUrl !== "string" || rawBaseUrl.length === 0) {
258
+ throw new ConfigError(`${where}.baseUrl must be a non-empty URL string`, path);
259
+ }
260
+ let parsed: URL;
261
+ try {
262
+ parsed = new URL(rawBaseUrl);
263
+ } catch {
264
+ throw new ConfigError(`${where}.baseUrl is not a valid URL: ${rawBaseUrl}`, path);
265
+ }
266
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
267
+ throw new ConfigError(`${where}.baseUrl must be http(s), got ${parsed.protocol}`, path);
268
+ }
269
+ baseUrl = rawBaseUrl;
270
+ }
234
271
 
235
- const rawApi = value["api"];
236
- let api: string | undefined;
237
- if (rawApi !== undefined) {
238
- if (typeof rawApi !== "string" || !SUPPORTED_LOCAL_CLASSIFIER_APIS.includes(rawApi)) {
272
+ const rawApiKey = rawEntry["apiKey"];
273
+ let apiKey: string | undefined;
274
+ if (rawApiKey !== undefined) {
275
+ if (typeof rawApiKey !== "string" || rawApiKey.length === 0) {
276
+ throw new ConfigError(`${where}.apiKey must be a non-empty string`, path);
277
+ }
278
+ apiKey = rawApiKey;
279
+ }
280
+ if (baseUrl !== undefined && apiKey === undefined) {
281
+ // A direct endpoint needs a bearer token to send. Local servers ignore
282
+ // it, but the token is still the thing that makes "direct endpoint"
283
+ // explicit rather than a silent default.
239
284
  throw new ConfigError(
240
- `${where}.jev.api must be one of: ${SUPPORTED_LOCAL_CLASSIFIER_APIS.join(", ")}`,
241
- where,
285
+ `${where}.apiKey is required with baseUrl - use "local" for endpoints that ignore the bearer token`,
286
+ path,
242
287
  );
243
288
  }
244
- api = rawApi;
245
- }
246
- if (api !== undefined && baseUrl === undefined) {
247
- throw new ConfigError(`${where}.jev.api requires ${where}.jev.baseUrl`, where);
248
- }
249
-
250
- const rawApiKey = value["apiKey"];
251
- let apiKey: string | undefined;
252
- if (rawApiKey !== undefined) {
253
- if (typeof rawApiKey !== "string" || rawApiKey.length === 0) {
254
- throw new ConfigError(`${where}.jev.apiKey must be a non-empty string`, where);
289
+ if (apiKey !== undefined && baseUrl === undefined) {
290
+ throw new ConfigError(`${where}.apiKey requires ${where}.baseUrl`, path);
255
291
  }
256
- apiKey = rawApiKey;
257
- }
258
- if (apiKey !== undefined && baseUrl === undefined) {
259
- throw new ConfigError(`${where}.jev.apiKey requires ${where}.jev.baseUrl`, where);
292
+ if (baseUrl === undefined) {
293
+ warnings.push(
294
+ `${where} has no connection details; it can only resolve through a classifier catalog (pi has one, OpenCode does not)`,
295
+ );
296
+ }
297
+ entries[address] = {
298
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
299
+ ...(apiKey !== undefined ? { apiKey } : {}),
300
+ };
260
301
  }
302
+ return entries;
303
+ }
261
304
 
305
+ /**
306
+ * Resolve a chain's `decisionModel:` address against the `decisionModels:` map.
307
+ *
308
+ * A map entry with a `baseUrl` pins the endpoint. Without one the address stays
309
+ * catalog-resolved, which pi can do and OpenCode cannot - that stays a visible
310
+ * "no classifier" cycle there instead of a load error, because the same file is
311
+ * valid on pi.
312
+ */
313
+ export function resolveDecisionModel(
314
+ decisionModels: Record<DecisionModelId, DecisionModelConfig> | undefined,
315
+ address: DecisionModelId | undefined,
316
+ ): DecisionModelConfig | undefined {
317
+ if (address === undefined) return undefined;
318
+ const { provider, id } = splitAddress(address, "decisionModel");
319
+ const entry = decisionModels?.[address];
262
320
  return {
263
321
  provider,
264
322
  id,
265
- ...(baseUrl !== undefined ? { baseUrl } : {}),
266
- ...(api !== undefined ? { api } : {}),
267
- ...(apiKey !== undefined ? { apiKey } : {}),
323
+ ...(entry?.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}),
324
+ ...(entry?.apiKey !== undefined ? { apiKey: entry.apiKey } : {}),
268
325
  };
269
326
  }
270
327
 
271
- export type { SwitchbackConfig, SwitchbackFileConfig, JevConfig } from "./types.ts";
328
+ export type {
329
+ SwitchbackConfig,
330
+ SwitchbackFileConfig,
331
+ DecisionModelConfig,
332
+ DecisionModelEntry,
333
+ DecisionModelId,
334
+ } from "./types.ts";
272
335
 
273
- function parseSwitchbackModel(value: unknown, index: number): SwitchbackConfig {
336
+ function parseSwitchbackModel(value: unknown, index: number, warnings: string[]): SwitchbackConfig {
274
337
  const where = `models[${index}]`;
275
338
  if (!isRecord(value)) throw new ConfigError(`${where} must be an object`, where);
339
+ noteUnknownKeys(value, MODEL_KEYS, where, warnings);
276
340
  const id = value["id"];
277
341
  const name = value["name"];
278
342
  const fallbacks = value["fallbacks"];
@@ -287,13 +351,14 @@ function parseSwitchbackModel(value: unknown, index: number): SwitchbackConfig {
287
351
  throw new ConfigError(`${where}.fallbacks must be a non-empty array of "provider/id" strings`, where);
288
352
  }
289
353
  const parsedFallbacks = fallbacks.map((entry, i) => parseModelId(entry, `${where}.fallbacks[${i}]`));
290
- const jev = parseJev(value["jev"], where);
354
+ const rawDecisionModel = value["decisionModel"];
355
+ const decisionModel = rawDecisionModel === undefined ? undefined : parseDecisionModelId(rawDecisionModel, where);
291
356
  const idleReset = parseIdleReset(value["idleReset"], where);
292
357
  return {
293
358
  id: fullId,
294
359
  name,
295
360
  fallbacks: parsedFallbacks,
296
- ...(jev ? { jev } : {}),
361
+ ...(decisionModel !== undefined ? { decisionModel } : {}),
297
362
  ...(idleReset !== undefined ? { idleReset } : {}),
298
363
  };
299
364
  }
@@ -307,50 +372,6 @@ function parseIdleReset(value: unknown, where: string): IdleResetConfig | undefi
307
372
  return value as IdleResetConfig;
308
373
  }
309
374
 
310
- /** Parse the top-level `decisionModels:` list. Names must be unique. */
311
- function parseDecisionModels(value: unknown, path: string): DecisionModelEntry[] {
312
- if (value === undefined) return [];
313
- if (!Array.isArray(value)) throw new ConfigError("decisionModels must be an array", path);
314
- const entries: DecisionModelEntry[] = [];
315
- const seen = new Set<string>();
316
- value.forEach((entry, i) => {
317
- const where = `decisionModels[${i}]`;
318
- if (!isRecord(entry)) throw new ConfigError(`${where} must be an object`, path);
319
- const name = entry["name"];
320
- if (typeof name !== "string" || name.length === 0) {
321
- throw new ConfigError(`${where}.name must be a non-empty string`, path);
322
- }
323
- if (seen.has(name)) throw new ConfigError(`decisionModels contains the name "${name}" more than once`, path);
324
- seen.add(name);
325
- const inline = parseJevInline(entry, where);
326
- entries.push({ name, ...inline });
327
- });
328
- return entries;
329
- }
330
-
331
- /**
332
- * Resolve a model's `jev` to an inline config. A `decisionModel:` reference is
333
- * looked up in the given decision-model list; an unknown name is an actionable
334
- * load-time error, not a silent fall-through to no classifier.
335
- */
336
- export function resolveJevConfig(
337
- decisionModels: readonly DecisionModelEntry[] | undefined,
338
- jev: JevRef | undefined,
339
- ): JevConfig | undefined {
340
- if (jev === undefined) return undefined;
341
- if (!("decisionModel" in jev)) return jev;
342
- const name = jev.decisionModel;
343
- const found = decisionModels?.find((d) => d.name === name);
344
- if (found === undefined) {
345
- throw new ConfigError(
346
- `jev.decisionModel "${name}" is not defined in decisionModels (known: ${(decisionModels ?? []).map((d) => d.name).join(", ") || "none"})`,
347
- "<config>",
348
- );
349
- }
350
- const { name: _ignored, ...inline } = found;
351
- return inline;
352
- }
353
-
354
375
  const SECRETS_PREFIX = "secret:";
355
376
 
356
377
  /**
@@ -382,48 +403,48 @@ export function resolveSecretApiKey(
382
403
  return value;
383
404
  }
384
405
 
385
- /** Resolve every model's jev reference against the decision-model list. */
386
- function resolveAllJevs(
406
+ /** Resolve every chain's `decisionModel:` address against the map, and the map's secret refs. */
407
+ function resolveFile(
387
408
  file: SwitchbackFileConfig,
388
409
  secrets: { get(name: string): string | undefined } | undefined = undefined,
389
410
  ): ResolvedSwitchbackFileConfig {
390
- const resolvedDecisionModels =
411
+ const resolvedDecisionModels: Record<DecisionModelId, DecisionModelConfig> | undefined =
391
412
  file.decisionModels === undefined
392
413
  ? undefined
393
- : file.decisionModels.map((entry) => {
394
- const { apiKey, ...rest } = entry;
395
- const resolvedApiKey = resolveSecretApiKey(apiKey, secrets);
396
- return resolvedApiKey === undefined ? entry : { ...rest, apiKey: resolvedApiKey };
397
- });
414
+ : Object.fromEntries(
415
+ Object.entries(file.decisionModels).map(([address, entry]) => {
416
+ const { provider, id } = splitAddress(address, `decisionModels.${address}`);
417
+ const apiKey = resolveSecretApiKey(entry.apiKey, secrets);
418
+ const descriptor: DecisionModelConfig = {
419
+ provider,
420
+ id,
421
+ ...(entry.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}),
422
+ ...(apiKey !== undefined ? { apiKey } : {}),
423
+ };
424
+ return [address, descriptor];
425
+ }),
426
+ );
398
427
  return {
399
428
  models: file.models.map((model) => {
400
- const { jev, ...rest } = model;
401
- const inline = resolveJevConfig(
402
- file.decisionModels === undefined ? undefined : resolvedDecisionModels,
403
- jev,
404
- );
405
- if (inline === undefined) return rest;
406
- const resolvedInline: JevConfig = (() => {
407
- const apiKey = inline.apiKey;
408
- const resolvedApiKey = resolveSecretApiKey(apiKey, secrets);
409
- return resolvedApiKey === undefined ? inline : { ...inline, apiKey: resolvedApiKey };
410
- })();
411
- return { ...rest, jev: resolvedInline };
429
+ const { decisionModel, ...rest } = model;
430
+ const resolved = resolveDecisionModel(resolvedDecisionModels, decisionModel);
431
+ return resolved === undefined ? rest : { ...rest, decisionModel: resolved };
412
432
  }),
413
- ...(file.decisionModels !== undefined && file.decisionModels.length > 0 && resolvedDecisionModels !== undefined
433
+ ...(resolvedDecisionModels !== undefined && Object.keys(resolvedDecisionModels).length > 0
414
434
  ? { decisionModels: resolvedDecisionModels }
415
435
  : {}),
416
436
  ...(file.debug !== undefined ? { debug: file.debug } : {}),
417
437
  };
418
438
  }
419
439
 
420
- function parseFileConfig(value: unknown, path: string): SwitchbackFileConfig {
440
+ function parseFileConfig(value: unknown, path: string, warnings: string[]): SwitchbackFileConfig {
421
441
  if (!isRecord(value)) throw new ConfigError("root must be an object", path);
442
+ noteUnknownKeys(value, FILE_KEYS, "config", warnings);
422
443
  const models = value["models"];
423
444
  if (!Array.isArray(models) || models.length === 0) {
424
445
  throw new ConfigError("models must be a non-empty array", path);
425
446
  }
426
- const parsed = models.map((entry, i) => parseSwitchbackModel(entry, i));
447
+ const parsed = models.map((entry, i) => parseSwitchbackModel(entry, i, warnings));
427
448
  // Any set of virtual model ids is valid - `switchback/auto` is the conventional
428
449
  // first entry, not a requirement. An unknown virtual id is already surfaced by
429
450
  // findModelConfig() at extension load, and index.ts registers one virtual model
@@ -440,39 +461,27 @@ function parseFileConfig(value: unknown, path: string): SwitchbackFileConfig {
440
461
  if (rawDebug !== undefined && typeof rawDebug !== "boolean") {
441
462
  throw new ConfigError("debug must be a boolean", path);
442
463
  }
443
- const decisionModels = parseDecisionModels(value["decisionModels"], path);
444
- // An orphan `decisionModels` entry is a silent foot-gun: the router would
445
- // have no classifier for every model whose `jev` references the orphan and
446
- // would emit blind-cycle traffic (the live crash store showed exactly this
447
- // on 2026-10-06). Reject only when NONE of the entries are referenced -
448
- // partial references are fine: at least one classifier still resolves and
449
- // the user can keep the others around to wire up later.
450
- if (decisionModels.length > 0) {
451
- const referenced = new Set<string>();
452
- for (const model of parsed) {
453
- if (model.jev !== undefined && "decisionModel" in model.jev) {
454
- referenced.add(model.jev.decisionModel);
464
+ const decisionModels = parseDecisionModels(value["decisionModels"], path, warnings);
465
+ // A `decisionModels` entry no chain references is harmless but almost always a
466
+ // leftover: warn (do not fail) so the block does not quietly rot.
467
+ if (decisionModels !== undefined) {
468
+ const referenced = new Set(parsed.map((model) => model.decisionModel).filter((id) => id !== undefined));
469
+ for (const address of Object.keys(decisionModels)) {
470
+ if (!referenced.has(address)) {
471
+ warnings.push(
472
+ `decisionModels.${address} is not referenced by any models[].decisionModel and was ignored`,
473
+ );
455
474
  }
456
475
  }
457
- const orphans = decisionModels.filter((d) => !referenced.has(d.name));
458
- if (orphans.length === decisionModels.length) {
459
- throw new ConfigError(
460
- "decisionModels entries are not referenced by any model.jev.decisionModel: " +
461
- `${orphans.map((d) => `"${d.name}"`).join(", ")}. ` +
462
- 'A decisionModels entry must be referenced by at least one model.jev.decisionModel, otherwise the router sees no classifier and reports it as "not configured" - ' +
463
- `reference one from a model ("${parsed[0]?.id ?? "<first model>"}".jev.decisionModel: <name>) or remove the decisionModels block.`,
464
- path,
465
- );
466
- }
467
476
  }
468
477
  return {
469
478
  models: parsed,
470
- ...(decisionModels.length > 0 ? { decisionModels } : {}),
479
+ ...(decisionModels !== undefined ? { decisionModels } : {}),
471
480
  ...(rawDebug !== undefined ? { debug: rawDebug } : {}),
472
481
  };
473
482
  }
474
483
 
475
- function readAndParse(path: string): SwitchbackFileConfig {
484
+ function readAndParse(path: string, warnings: string[]): SwitchbackFileConfig {
476
485
  // The only .json file in this project's surface is the simulate fixture;
477
486
  // anything else ending in .json is a user config mistake.
478
487
  if (path.endsWith(".json")) {
@@ -490,7 +499,10 @@ function readAndParse(path: string): SwitchbackFileConfig {
490
499
  path,
491
500
  );
492
501
  }
493
- return parseFileConfig(raw, path);
502
+ const layerWarnings: string[] = [];
503
+ const parsed = parseFileConfig(raw, path, layerWarnings);
504
+ for (const warning of layerWarnings) warnings.push(`${warning} (${path})`);
505
+ return parsed;
494
506
  }
495
507
 
496
508
  /**
@@ -512,8 +524,8 @@ function aggregateConfigs(global: SwitchbackFileConfig, project: SwitchbackFileC
512
524
  }
513
525
  return {
514
526
  models: merged,
515
- // Decision models follow the same layering rule as debug: the project list
516
- // replaces the global list when present, so references resolve against one
527
+ // Decision models follow the same layering rule as debug: the project map
528
+ // replaces the global map when present, so addresses resolve against one
517
529
  // predictable set. (A project layer without decisionModels keeps the global set.)
518
530
  ...(project.decisionModels !== undefined
519
531
  ? { decisionModels: project.decisionModels }
@@ -554,11 +566,15 @@ export interface SecretResolver {
554
566
  *
555
567
  * `dirs` overrides the layer locations; a host other than pi passes its own
556
568
  * agent config directory so its overlay participates.
569
+ *
570
+ * `warnings` carries recognised-but-suspicious findings (unknown keys, retired
571
+ * keys, unreferenced decision models). They never fail the load: a host shows
572
+ * them through its own notify channel.
557
573
  */
558
574
  export function loadConfig(
559
575
  secrets?: SecretResolver,
560
576
  dirs?: ConfigLayerDirs,
561
- ): { config: ResolvedSwitchbackFileConfig; source: string } {
577
+ ): { config: ResolvedSwitchbackFileConfig; source: string; warnings: string[] } {
562
578
  const [neutralPath, agentPath, projectPath] = candidatePaths(dirs) as [string, string, string];
563
579
  const neutralExists = existsSync(neutralPath);
564
580
  const agentExists = existsSync(agentPath);
@@ -567,26 +583,27 @@ export function loadConfig(
567
583
  // Aggregate all existing layers, lowest precedence first.
568
584
  let merged: SwitchbackFileConfig | undefined;
569
585
  const sources: string[] = [];
586
+ const warnings: string[] = [];
570
587
 
571
588
  if (neutralExists) {
572
- merged = readAndParse(neutralPath);
589
+ merged = readAndParse(neutralPath, warnings);
573
590
  sources.push(resolve(neutralPath));
574
591
  }
575
592
  if (agentExists) {
576
- const agent = readAndParse(agentPath);
593
+ const agent = readAndParse(agentPath, warnings);
577
594
  sources.push(resolve(agentPath));
578
595
  merged = merged === undefined ? agent : aggregateConfigs(merged, agent);
579
596
  }
580
597
  if (projectExists) {
581
- const project = readAndParse(projectPath);
598
+ const project = readAndParse(projectPath, warnings);
582
599
  sources.push(resolve(projectPath));
583
600
  merged = merged === undefined ? project : aggregateConfigs(merged, project);
584
601
  }
585
602
 
586
603
  if (merged === undefined) {
587
- return { config: DEFAULT_CONFIG, source: "<default>" };
604
+ return { config: DEFAULT_CONFIG, source: "<default>", warnings };
588
605
  }
589
- return { config: resolveAllJevs(merged, secrets), source: sources.join(" + ") };
606
+ return { config: resolveFile(merged, secrets), source: sources.join(" + "), warnings };
590
607
  }
591
608
 
592
609
  /**
@@ -594,8 +611,10 @@ export function loadConfig(
594
611
  * loader applies; exposed so the interactive config editor validates a mutated
595
612
  * document at save time instead of maintaining a second rule set.
596
613
  */
597
- export function validateFileConfig(raw: unknown, path: string): SwitchbackFileConfig {
598
- return parseFileConfig(raw, path);
614
+ export function validateFileConfig(raw: unknown, path: string): { config: SwitchbackFileConfig; warnings: string[] } {
615
+ const warnings: string[] = [];
616
+ const config = parseFileConfig(raw, path, warnings);
617
+ return { config, warnings };
599
618
  }
600
619
 
601
620
  /** Look up the configuration entry for a given virtual model id. */
@@ -33,7 +33,7 @@
33
33
  import type { ClassifierContext, ClassifierQuestion } from "@earendil-works/pi-ai";
34
34
  import { callClassifier, readChoice, readConfidence, type NoClassifierReason } from "./classify.ts";
35
35
  import type { ClassifierPort } from "./ports.ts";
36
- import type { JevConfig, ModelId } from "./types.ts";
36
+ import type { DecisionModelConfig, ModelId } from "./types.ts";
37
37
 
38
38
  /**
39
39
  * Headroom kept free when judging whether a model can hold the current context.
@@ -71,7 +71,7 @@ export interface CandidateWindow {
71
71
 
72
72
  export interface ContextCandidateInput {
73
73
  registry: ClassifierPort;
74
- jev: JevConfig | undefined;
74
+ decisionModel: DecisionModelConfig | undefined;
75
75
  /** The deterministic pick: the floor the classifier may only reorder. */
76
76
  preferred: ModelId;
77
77
  /** Current context size in tokens. */
@@ -145,7 +145,7 @@ export async function chooseContextCandidate(input: ContextCandidateInput): Prom
145
145
  .map((candidate) => `${candidate.id}: ${compactTokens(candidate.contextWindow)} window`)
146
146
  .join("; "),
147
147
  };
148
- const call = await callClassifier(input.registry, input.jev, {
148
+ const call = await callClassifier(input.registry, input.decisionModel, {
149
149
  state,
150
150
  questions: { candidate: criteria },
151
151
  });
package/src/dispatch.ts CHANGED
@@ -69,7 +69,7 @@ export async function resolveDispatchLevel(
69
69
  const choice = await chooseThinkingLevel({
70
70
  registry,
71
71
  support: inputs.support,
72
- jev: modelConfig.jev,
72
+ decisionModel: modelConfig.decisionModel,
73
73
  model,
74
74
  requested,
75
75
  context,
package/src/failure.ts CHANGED
@@ -29,7 +29,7 @@ import { classifyError, PROMPT_VERSION, type NoClassifierReason } from "./classi
29
29
  import { recordCrash, type CrashAction } from "./crashes.ts";
30
30
  import type { AvailabilityPort, ClassifierPort, ModelRegistryPort, NotifyPort, RouterRegistryPort } from "./ports.ts";
31
31
  import { blockModel, isBlocked, unblockModel } from "./state.ts";
32
- import type { BlockedMap, ClassifiedError, JevConfig, ModelId } from "./types.ts";
32
+ import type { BlockedMap, ClassifiedError, DecisionModelConfig, ModelId } from "./types.ts";
33
33
 
34
34
  /** Maximum number of transient retries on the same model before the router moves on. */
35
35
  export const MAX_TRANSIENT_RETRIES = 1;
@@ -112,14 +112,14 @@ export async function assessFailure(opts: {
112
112
  failedId: string | undefined;
113
113
  errorMessage: string;
114
114
  stopReason: StopReason | undefined;
115
- jev: JevConfig | undefined;
115
+ decisionModel: DecisionModelConfig | undefined;
116
116
  registry: ClassifierPort;
117
117
  now: number;
118
118
  blocked: BlockedMap;
119
119
  transientRetries: number;
120
120
  }): Promise<AssessedFailure> {
121
- const { failedId, errorMessage, stopReason, jev, registry, now, blocked } = opts;
122
- const result = await classifyError(errorMessage, registry, jev, now, stopReason);
121
+ const { failedId, errorMessage, stopReason, decisionModel, registry, now, blocked } = opts;
122
+ const result = await classifyError(errorMessage, registry, decisionModel, now, stopReason);
123
123
  if (result.kind === "no-classifier") {
124
124
  // The universal cycle: advance, but never fabricate a class and never write a
125
125
  // block - there is nothing to justify one with.
@@ -186,7 +186,7 @@ export async function observeUnretriedFailure(opts: {
186
186
  failedId: string | undefined;
187
187
  errorMessage: string;
188
188
  stopReason: StopReason | undefined;
189
- jev: JevConfig | undefined;
189
+ decisionModel: DecisionModelConfig | undefined;
190
190
  registry: RouterRegistryPort;
191
191
  now: number;
192
192
  blocked: BlockedMap;
@@ -197,7 +197,7 @@ export async function observeUnretriedFailure(opts: {
197
197
  failedId,
198
198
  errorMessage,
199
199
  stopReason: opts.stopReason,
200
- jev: opts.jev,
200
+ decisionModel: opts.decisionModel,
201
201
  registry: opts.registry,
202
202
  now,
203
203
  blocked: opts.blocked,
package/src/idle.ts CHANGED
@@ -31,7 +31,7 @@
31
31
  import type { ClassifierContext, ClassifierQuestion, Message } from "@earendil-works/pi-ai";
32
32
  import { callClassifier, readChoice, readConfidence, type NoClassifierReason } from "./classify.ts";
33
33
  import type { ClassifierPort } from "./ports.ts";
34
- import type { IdleResetConfig, JevConfig, ModelId } from "./types.ts";
34
+ import type { IdleResetConfig, DecisionModelConfig, ModelId } from "./types.ts";
35
35
 
36
36
  /** Every accepted `idleReset:` value, in menu order. */
37
37
  export const IDLE_RESET_OPTIONS: readonly IdleResetConfig[] = [
@@ -136,7 +136,7 @@ export interface IdleResetDecision {
136
136
 
137
137
  export interface IdleResetInput {
138
138
  registry: ClassifierPort;
139
- jev: JevConfig | undefined;
139
+ decisionModel: DecisionModelConfig | undefined;
140
140
  /** The virtual model's `idleReset:` setting. */
141
141
  option: IdleResetConfig | undefined;
142
142
  messages: readonly Message[];
@@ -210,7 +210,7 @@ async function askClassifier(input: IdleResetInput, idle: number): Promise<IdleR
210
210
  preference_order: input.candidates.length > 0 ? input.candidates.join(", ") : "(none)",
211
211
  blocked_models: input.blockedNotes.length > 0 ? input.blockedNotes.join(", ") : "(none)",
212
212
  };
213
- const call = await callClassifier(input.registry, input.jev, {
213
+ const call = await callClassifier(input.registry, input.decisionModel, {
214
214
  state,
215
215
  questions: { idle_reset: criteria },
216
216
  });