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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchback-core",
3
- "version": "2026.10.14",
3
+ "version": "2026.10.16",
4
4
  "type": "module",
5
5
  "description": "Host-agnostic core for switchback: fallback routing, error classification ports, block state and configuration.",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
+ "@typesafe-ai/sdk": "^0.7.0",
32
33
  "yaml": "^2.9.1"
33
34
  },
34
35
  "peerDependencies": {
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { AvailabilityPort, HostModel } from "./ports.ts";
22
- import type { Availability, BlockedMap, ModelId, ResolvedEntry, ResolvedFallbacks } from "./types.ts";
22
+ import type { Availability, BlockedMap, DisabledMap, ModelId, ResolvedEntry, ResolvedFallbacks } from "./types.ts";
23
23
 
24
24
  function splitModelId(modelId: ModelId): { provider: string; id: string } | undefined {
25
25
  const slash = modelId.indexOf("/");
@@ -124,6 +124,7 @@ export function pickNextEffective(
124
124
  now: number,
125
125
  startAfter?: ModelId,
126
126
  excluded?: ReadonlySet<ModelId>,
127
+ disabled?: DisabledMap,
127
128
  ): { modelId: ModelId; degraded: boolean } | undefined {
128
129
  const effective = effectiveIds(resolved);
129
130
  if (effective.length === 0) return undefined;
@@ -132,14 +133,14 @@ export function pickNextEffective(
132
133
  for (let i = 0; i < count; i++) {
133
134
  const candidate = effective[(start + i) % count]!;
134
135
  if (candidate === skip || excluded?.has(candidate) === true) continue;
136
+ if (disabled?.[candidate] !== undefined) continue;
135
137
  const blockedUntil = blocked[candidate];
136
138
  if (blockedUntil !== undefined && blockedUntil > now) continue;
137
139
  return { modelId: candidate, degraded: false };
138
140
  }
139
141
  // Degraded mode: only when exactly one effective entry exists. Edge case #4.
140
- // An excluded model (one that already failed in this cascade) is never offered,
141
- // not even degraded.
142
- if (count === 1 && excluded?.has(effective[0]!) !== true) {
142
+ // An excluded or manually disabled model is never offered, not even degraded.
143
+ if (count === 1 && excluded?.has(effective[0]!) !== true && disabled?.[effective[0]!] === undefined) {
143
144
  return { modelId: effective[0]!, degraded: true };
144
145
  }
145
146
  // Multiple effective entries all blocked: return undefined so the router
package/src/classify.ts CHANGED
@@ -33,7 +33,7 @@
33
33
  * Kept deliberately: the directive explicitly allows typed signals from
34
34
  * pi, only the message-derived heuristics are forbidden.
35
35
  *
36
- * 2. CLASSIFIER: the configured `jev` entry via `ctx.modelRegistry.classify`.
36
+ * 2. CLASSIFIER: the configured `decisionModel` entry via `ctx.modelRegistry.classify`.
37
37
  * The structured-output answers (class / reset score / scope) are the
38
38
  * ONLY message-derived decisions. Score is mapped to ms via
39
39
  * `scoreToResetAtMs` (rubric index, 31d cap). Timeout, parse error,
@@ -90,7 +90,7 @@ const MAX_RESET_MS = 31 * 24 * 3_600_000;
90
90
  * v4 (2026-10-06): the `scope` question now anchors each option in wording the
91
91
  * message actually contains. v3 asked the model to judge counterfactuals ("other
92
92
  * models still work") and named only `unknown` in its instructions, so `unknown`
93
- * took the prior mass and won on every case measured - `typesafe/jev-latest`
93
+ * took the prior mass and won on every case measured - `typesafe/decisionModel-latest`
94
94
  * answered `unknown` at 84-92% with `account` second. The new text gives the
95
95
  * model the lexical cues an error message really carries (a named model, a key
96
96
  * or plan, an address) and asks for `unknown` only as a last resort. Verdicts
@@ -104,7 +104,7 @@ const MAX_RESET_MS = 31 * 24 * 3_600_000;
104
104
  * model or series / a credential, plan, balance or usage quota / an address or
105
105
  * region), which is a property of the question rather than of any provider.
106
106
  * Measured before and after on two held-out messages that were never in the
107
- * corpus: `typesafe/jev-latest` 7/7 with and without the examples, so the
107
+ * corpus: `typesafe/decisionModel-latest` 7/7 with and without the examples, so the
108
108
  * hosted classifier never needed them; `parable/tinyjev:latest` drops 7/7 ->
109
109
  * 5/7, losing `weekly token limit exhausted` and `monthly cap`, which the
110
110
  * smaller model needed a concrete anchor for. The hosted path is what the
@@ -239,9 +239,12 @@ function normaliseScope(value: unknown): ErrorScope {
239
239
  return "unknown";
240
240
  }
241
241
 
242
- /** Find a classifier in the model registry. The provider+id are looked up via JevConfig. */
243
- function findJev(registry: ClassifierPort, jev: { provider: string; id: string }): ClassifierEntry | undefined {
244
- return registry.findOfType("classifier", jev.provider, jev.id);
242
+ /** Find a classifier in the model registry. The provider+id are looked up via DecisionModelConfig. */
243
+ function findJev(
244
+ registry: ClassifierPort,
245
+ decisionModel: { provider: string; id: string },
246
+ ): ClassifierEntry | undefined {
247
+ return registry.findOfType("classifier", decisionModel.provider, decisionModel.id);
245
248
  }
246
249
 
247
250
  /**
@@ -340,14 +343,14 @@ export type ClassifierCallResult =
340
343
  */
341
344
  export async function callClassifier(
342
345
  registry: ClassifierPort,
343
- jev: { provider: string; id: string } | undefined,
346
+ decisionModel: { provider: string; id: string } | undefined,
344
347
  context: ClassifierContext,
345
348
  timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
346
349
  ): Promise<ClassifierCallResult> {
347
- if (jev === undefined) {
350
+ if (decisionModel === undefined) {
348
351
  return { kind: "no-classifier", reason: "not-configured" };
349
352
  }
350
- const handle = findJev(registry, jev);
353
+ const handle = findJev(registry, decisionModel);
351
354
  if (handle === undefined) {
352
355
  return { kind: "no-classifier", reason: "unresolvable" };
353
356
  }
@@ -408,7 +411,7 @@ export function readConfidence(result: ClassifierResult, questionId: string): nu
408
411
  export async function classifyError(
409
412
  message: string,
410
413
  registry: ClassifierPort,
411
- jev: { provider: string; id: string } | undefined,
414
+ decisionModel: { provider: string; id: string } | undefined,
412
415
  now: number = Date.now(),
413
416
  stopReason?: StopReason,
414
417
  ): Promise<ClassificationResult> {
@@ -442,7 +445,7 @@ export async function classifyError(
442
445
 
443
446
  // Branch 2: classifier. Any failure (not configured, unresolvable,
444
447
  // timeout, threw, unparseable) falls through to "no-classifier".
445
- const call = await callClassifier(registry, jev, {
448
+ const call = await callClassifier(registry, decisionModel, {
446
449
  state: classifierState(message),
447
450
  questions: JEV_QUESTIONS,
448
451
  });
@@ -11,10 +11,9 @@
11
11
  * Validation lives in exactly one place. Every mutation is structural only
12
12
  * (locate a node, set/splice/delete); `saveLayer()` re-validates the whole
13
13
  * document through the exported `validateFileConfig()` - the same gate the
14
- * loader applies - and then re-checks every `jev.decisionModel` reference
15
- * through the exported `resolveJevConfig()`. The editor therefore carries
16
- * no rule set of its own: a save either produces exactly what `loadConfig()`
17
- * would accept, or throws the loader's own ConfigError messages.
14
+ * loader applies. The editor therefore carries no rule set of its own: a save
15
+ * either produces exactly what `loadConfig()` would accept, or throws the
16
+ * loader's own ConfigError messages.
18
17
  *
19
18
  * Secrets: API keys never enter the file as literals. The decision-model
20
19
  * operations accept a secret NAME (`apiKeySecretName`), and the value is
@@ -36,8 +35,8 @@ import { existsSync, readFileSync } from "node:fs";
36
35
  import { join } from "node:path";
37
36
  import { writeFileAtomic } from "./atomic-write.ts";
38
37
  import { isMap, isSeq, parseDocument, type Document, type YAMLMap, type YAMLSeq } from "yaml";
39
- import { ConfigError, LOCAL_CLASSIFIER_APIS, piConfigDir, resolveJevConfig, validateFileConfig } from "./config.ts";
40
- import type { DecisionModelEntry, JevConfig, JevRef, ModelId, SwitchbackFileConfig } from "./types.ts";
38
+ import { ConfigError, piConfigDir, validateFileConfig } from "./config.ts";
39
+ import type { IdleResetConfig, ModelId, SwitchbackFileConfig } from "./types.ts";
41
40
 
42
41
  /** Which switchback.yaml layer the editor has open. */
43
42
  export type ConfigLayer = "global" | "project";
@@ -130,7 +129,7 @@ export function loadLayer(layer: ConfigLayer): LoadedLayer {
130
129
  * an invalid state.
131
130
  */
132
131
  export function readLayerConfig(loaded: LoadedLayer): SwitchbackFileConfig {
133
- return validateFileConfig(loaded.doc.toJSON(), loaded.path);
132
+ return validateFileConfig(loaded.doc.toJSON(), loaded.path).config;
134
133
  }
135
134
 
136
135
  function errorMessage(error: unknown): string {
@@ -262,163 +261,166 @@ export function moveFallback(loaded: LoadedLayer, virtualId: string, from: numbe
262
261
  }
263
262
 
264
263
  /**
265
- * Set or clear a model's classifier. `null` clears the key (the model then
266
- * reports and blind-cycles without classifying), the ref form points at a
267
- * `decisionModels:` entry by name, and the inline form writes a complete
268
- * `JevConfig`. Reference validity is enforced at save time through
269
- * `resolveJevConfig`, so a dangling name fails the save, not the next load.
264
+ * Set or clear a model's decision model. `null` clears the key (the model then
265
+ * reports and blind-cycles without classifying). The value is a decision-model
266
+ * address ("provider/model-id"); connection details, when the address needs a
267
+ * direct endpoint, live in the top-level `decisionModels:` map under the same
268
+ * address.
270
269
  */
271
- export function setDecisionModel(
272
- loaded: LoadedLayer,
273
- virtualId: string,
274
- value: JevConfig | { decisionModel: string } | null,
275
- ): void {
270
+ export function setDecisionModel(loaded: LoadedLayer, virtualId: string, address: string | null): void {
276
271
  const { node } = findModelNode(loaded, virtualId);
277
- if (value === null) {
278
- node.delete("jev");
272
+ if (address === null) {
273
+ node.delete("decisionModel");
279
274
  return;
280
275
  }
281
- node.set("jev", loaded.doc.createNode(value));
276
+ node.set("decisionModel", address);
282
277
  }
283
278
 
284
- /** A decision-model entry as the editor accepts it: the apiKey is a secret NAME, never a value. */
279
+ /** A decision-model entry as the editor accepts it. */
285
280
  export interface DecisionModelInput {
286
- name: string;
287
- provider: string;
288
- id: string;
281
+ /** "provider/model-id" - the map key, and the value a chain names. */
282
+ address: string;
283
+ /** Direct SystemOne endpoint; omit for catalog resolution. */
289
284
  baseUrl?: string;
290
- api?: string;
291
- /** Stored in YAML as `secret:<name>`; the value itself goes into the encrypted store. */
285
+ /**
286
+ * Secret-store name, written as `secret:<name>`. Omit for an endpoint that
287
+ * ignores the bearer token; the entry then carries `apiKey: local`.
288
+ */
292
289
  apiKeySecretName?: string;
293
290
  }
294
291
 
295
- /** Field-level patch for an existing decision model; `null` clears an optional field. */
292
+ /** Field-level patch for an existing decision model; `null` clears the field. */
296
293
  export interface DecisionModelPatch {
297
- provider?: string;
298
- id?: string;
294
+ /** `null` also clears `apiKey` (the loader rule: it requires baseUrl). */
299
295
  baseUrl?: string | null;
300
- api?: string | null;
296
+ /** `null` writes `apiKey: local` - only meaningful with a baseUrl. */
301
297
  apiKeySecretName?: string | null;
302
298
  }
303
299
 
304
- /** A file-level decisionModels entry as the parser produces it. */
305
- type DecisionModelNode = Omit<DecisionModelEntry, "apiKey"> & { apiKey?: string };
300
+ /** A `decisionModels:` map value as the parser produces it. */
301
+ interface DecisionModelNode {
302
+ baseUrl?: string;
303
+ apiKey?: string;
304
+ }
306
305
 
307
306
  function decisionModelToNode(doc: Document, input: DecisionModelInput): YAMLMap {
308
307
  const entry: DecisionModelNode = {
309
- name: input.name,
310
- provider: input.provider,
311
- id: input.id,
312
308
  ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
313
- ...(input.api !== undefined ? { api: input.api } : {}),
314
- ...(input.apiKeySecretName !== undefined ? { apiKey: `secret:${input.apiKeySecretName}` } : {}),
309
+ ...(input.baseUrl !== undefined
310
+ ? { apiKey: input.apiKeySecretName === undefined ? "local" : `secret:${input.apiKeySecretName}` }
311
+ : {}),
315
312
  };
316
313
  return doc.createNode(entry) as YAMLMap;
317
314
  }
318
315
 
319
- function requireDecisionModelsSeq(loaded: LoadedLayer): YAMLSeq {
320
- const seq = loaded.doc.get("decisionModels");
321
- if (seq === undefined || seq === null) {
322
- const created = loaded.doc.createNode([]) as YAMLSeq;
316
+ function requireDecisionModelsMap(loaded: LoadedLayer): YAMLMap {
317
+ const map = loaded.doc.get("decisionModels");
318
+ if (map === undefined || map === null) {
319
+ const created = loaded.doc.createNode({}) as YAMLMap;
323
320
  loaded.doc.set("decisionModels", created);
324
321
  return created;
325
322
  }
326
- if (!isSeq(seq)) throw new ConfigError("decisionModels must be an array", loaded.path);
327
- return seq;
323
+ if (!isMap(map)) {
324
+ throw new ConfigError('decisionModels must be a map keyed by "provider/model-id"', loaded.path);
325
+ }
326
+ return map;
328
327
  }
329
328
 
330
- /** Find a decisionModels entry node by name (the reference key). */
331
- function findDecisionModelNode(loaded: LoadedLayer, name: string): { seq: YAMLSeq; index: number; node: YAMLMap } {
332
- const seq = requireDecisionModelsSeq(loaded);
333
- for (const [index, item] of seq.items.entries()) {
334
- if (item === null || !isMap(item)) continue;
335
- if (item.get("name") === name) return { seq, index, node: item };
329
+ /** Find a decisionModels entry node by its address (the map key). */
330
+ function findDecisionModelNode(loaded: LoadedLayer, address: string): { map: YAMLMap; node: YAMLMap } {
331
+ const map = requireDecisionModelsMap(loaded);
332
+ const node = map.get(address, true);
333
+ if (node === undefined || node === null || !isMap(node)) {
334
+ throw new ConfigError(`decisionModels.${address} not found`, loaded.path);
336
335
  }
337
- throw new ConfigError(`decisionModel "${name}" not found`, loaded.path);
336
+ return { map, node };
338
337
  }
339
338
 
340
- /** Append a decision-model entry (duplicate names fail the save via `validateFileConfig`). */
339
+ /** Add a decision-model entry (duplicate addresses fail the save via `validateFileConfig`). */
341
340
  export function addDecisionModel(loaded: LoadedLayer, input: DecisionModelInput): void {
342
- const seq = requireDecisionModelsSeq(loaded);
343
- for (const item of seq.items) {
344
- if (item !== null && isMap(item) && item.get("name") === input.name) {
345
- throw new ConfigError(`decisionModels contains the name "${input.name}" more than once`, loaded.path);
346
- }
341
+ const map = requireDecisionModelsMap(loaded);
342
+ if (map.has(input.address)) {
343
+ throw new ConfigError(`decisionModels already contains "${input.address}"`, loaded.path);
347
344
  }
348
- seq.items.push(decisionModelToNode(loaded.doc, input));
345
+ map.set(input.address, decisionModelToNode(loaded.doc, input));
349
346
  }
350
347
 
351
- /** Patch one decision model's fields. `baseUrl: null` also clears `api`/`apiKey` (loader rule: they require baseUrl). */
352
- export function updateDecisionModel(loaded: LoadedLayer, name: string, patch: DecisionModelPatch): void {
353
- const { node } = findDecisionModelNode(loaded, name);
354
- if (patch.provider !== undefined) node.set("provider", patch.provider);
355
- if (patch.id !== undefined) node.set("id", patch.id);
348
+ /** Patch one decision model's connection details. */
349
+ export function updateDecisionModel(loaded: LoadedLayer, address: string, patch: DecisionModelPatch): void {
350
+ const { node } = findDecisionModelNode(loaded, address);
356
351
  if (patch.baseUrl !== undefined) {
357
352
  if (patch.baseUrl === null) {
358
353
  node.delete("baseUrl");
359
- // api and apiKey are only valid alongside baseUrl; clearing baseUrl clears them.
360
- node.delete("api");
354
+ // apiKey is only valid alongside baseUrl; clearing baseUrl clears it.
361
355
  node.delete("apiKey");
362
356
  } else {
363
357
  node.set("baseUrl", patch.baseUrl);
358
+ if (!node.has("apiKey")) node.set("apiKey", "local");
364
359
  }
365
360
  }
366
- if (patch.api !== undefined) {
367
- if (patch.api === null) node.delete("api");
368
- else node.set("api", patch.api);
369
- }
370
361
  if (patch.apiKeySecretName !== undefined) {
371
- if (patch.apiKeySecretName === null) node.delete("apiKey");
372
- else node.set("apiKey", `secret:${patch.apiKeySecretName}`);
362
+ node.set("apiKey", patch.apiKeySecretName === null ? "local" : `secret:${patch.apiKeySecretName}`);
373
363
  }
374
364
  }
375
365
 
376
366
  /**
377
- * Rename a decision model and rewrite every `jev.decisionModel` reference to
378
- * it in the same transaction, so a rename cannot dangle references.
367
+ * Rename a decision-model address and rewrite every `models[].decisionModel`
368
+ * that names it in the same transaction, so a rename cannot dangle references.
379
369
  */
380
- export function renameDecisionModel(loaded: LoadedLayer, oldName: string, newName: string): void {
381
- if (oldName !== newName) {
382
- const seq = requireDecisionModelsSeq(loaded);
383
- for (const item of seq.items) {
384
- if (item !== null && isMap(item) && item.get("name") === newName) {
385
- throw new ConfigError(`decisionModels contains the name "${newName}" more than once`, loaded.path);
386
- }
370
+ export function renameDecisionModel(loaded: LoadedLayer, oldAddress: string, newAddress: string): void {
371
+ if (oldAddress !== newAddress) {
372
+ const { map, node } = findDecisionModelNode(loaded, oldAddress);
373
+ if (map.has(newAddress)) {
374
+ throw new ConfigError(`decisionModels already contains "${newAddress}"`, loaded.path);
387
375
  }
376
+ map.delete(oldAddress);
377
+ map.set(newAddress, node);
388
378
  }
389
- const { node } = findDecisionModelNode(loaded, oldName);
390
- node.set("name", newName);
391
379
  for (const item of requireModelsSeq(loaded).items) {
392
380
  if (item === null || !isMap(item)) continue;
393
- const jev = item.get("jev");
394
- if (jev === null || jev === undefined || !isMap(jev)) continue;
395
- if (jev.get("decisionModel") === oldName) jev.set("decisionModel", newName);
381
+ if (item.get("decisionModel") === oldAddress) item.set("decisionModel", newAddress);
396
382
  }
397
383
  }
398
384
 
399
385
  /**
400
- * Remove a decision model. References are NOT rewritten: a model that still
401
- * names the removed entry fails `saveLayer()` through `resolveJevConfig` with
402
- * the loader's actionable message, so the user decides what happens to the
403
- * referencing model instead of the editor silently clearing it.
386
+ * Remove a decision model. References are NOT rewritten: a chain that still
387
+ * names the removed address falls back to catalog resolution, which pi can do
388
+ * and OpenCode cannot - the user decides what happens to the referencing model
389
+ * instead of the editor silently clearing it.
404
390
  */
405
- export function removeDecisionModel(loaded: LoadedLayer, name: string): void {
406
- const { seq, index } = findDecisionModelNode(loaded, name);
407
- seq.items.splice(index, 1);
391
+ export function removeDecisionModel(loaded: LoadedLayer, address: string): void {
392
+ const { map } = findDecisionModelNode(loaded, address);
393
+ map.delete(address);
394
+ // An empty map is litter: drop the key so the file reads as if no entry was ever made.
395
+ if (map.items.length === 0) loaded.doc.delete("decisionModels");
408
396
  }
409
397
 
410
- /** How many configured models reference a decision model (the dialogue's confirm/warning text). */
411
- export function countDecisionModelReferences(loaded: LoadedLayer, name: string): number {
398
+ /** How many configured models name a decision model (the dialogue's confirm/warning text). */
399
+ export function countDecisionModelReferences(loaded: LoadedLayer, address: string): number {
412
400
  let count = 0;
413
401
  for (const item of requireModelsSeq(loaded).items) {
414
402
  if (item === null || !isMap(item)) continue;
415
- const jev = item.get("jev");
416
- if (jev === null || jev === undefined || !isMap(jev)) continue;
417
- if (jev.get("decisionModel") === name) count += 1;
403
+ if (item.get("decisionModel") === address) count += 1;
418
404
  }
419
405
  return count;
420
406
  }
421
407
 
408
+ /**
409
+ * Set or clear a model's `idleReset:` option. A duration ("30m", "2h", ...)
410
+ * returns to the head of the fallback list after that much idle time;
411
+ * `classifier` asks the decision model; `null` deletes the key, which is the
412
+ * loader's default (`never`). Validation of the value itself is the loader's
413
+ * job at save time, so an unknown value surfaces with the loader's message.
414
+ */
415
+ export function setIdleReset(loaded: LoadedLayer, virtualId: string, option: IdleResetConfig | null): void {
416
+ const { node } = findModelNode(loaded, virtualId);
417
+ if (option === null) {
418
+ node.delete("idleReset");
419
+ return;
420
+ }
421
+ node.set("idleReset", option);
422
+ }
423
+
422
424
  /** Set the top-level `debug:` flag (per-switch diagnostics notifications). */
423
425
  export function setDebug(loaded: LoadedLayer, enabled: boolean): void {
424
426
  loaded.doc.set("debug", enabled);
@@ -427,25 +429,17 @@ export function setDebug(loaded: LoadedLayer, enabled: boolean): void {
427
429
  /**
428
430
  * Validate the mutated document through the loader's single gate and write it
429
431
  * atomically. Throws ConfigError (the loader's own messages) when the edited
430
- * state is not a config `loadConfig()` would accept - including dangling
431
- * `jev.decisionModel` references, checked here through the exported
432
- * `resolveJevConfig`. On a throw the in-memory document keeps the edit so the
433
- * dialogue can re-enter the same editor for a fix.
432
+ * state is not a config `loadConfig()` would accept. On a throw the in-memory
433
+ * document keeps the edit so the dialogue can re-enter the same editor for a fix.
434
434
  */
435
435
  export function saveLayer(loaded: LoadedLayer): void {
436
436
  if (loaded.doc.contents === undefined || loaded.doc.contents === null) {
437
437
  loaded.doc.contents = loaded.doc.createNode({});
438
438
  }
439
- const parsed = validateFileConfig(loaded.doc.toJSON(), loaded.path);
440
- for (const model of parsed.models) {
441
- resolveJevConfig(parsed.decisionModels, model.jev as JevRef | undefined);
442
- }
439
+ validateFileConfig(loaded.doc.toJSON(), loaded.path);
443
440
  try {
444
441
  writeFileAtomic(loaded.path, loaded.doc.toString());
445
442
  } catch (error) {
446
443
  throw new ConfigError(`cannot write config: ${errorMessage(error)}`, loaded.path);
447
444
  }
448
445
  }
449
-
450
- /** The wire APIs a direct-endpoint decision model may use (re-exported for the dialogue menu). */
451
- export { LOCAL_CLASSIFIER_APIS };