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/index.ts CHANGED
@@ -47,7 +47,24 @@ export {
47
47
  CONTEXT_CANDIDATE_PROMPT_VERSION,
48
48
  } from "./context-fit.ts";
49
49
  export type { CandidateWindow, ContextCandidateChoice, ContextCandidateInput } from "./context-fit.ts";
50
- export { blockModel, isBlocked, readBlockedMap, unblockModel, stateFilePath } from "./state.ts";
50
+ export {
51
+ blockModel,
52
+ clearDisabled,
53
+ disableModel,
54
+ disabledFilePath,
55
+ enableModel,
56
+ getPinnedModel,
57
+ isBlocked,
58
+ isDisabled,
59
+ pinFilePath,
60
+ readBlockedMap,
61
+ readDisabledMap,
62
+ readPinMap,
63
+ setPinnedModel,
64
+ stateFilePath,
65
+ unblockModel,
66
+ } from "./state.ts";
67
+ export type { PinMap } from "./state.ts";
51
68
  export {
52
69
  readCrashMap,
53
70
  recordCrash,
@@ -70,13 +87,17 @@ export {
70
87
  ConfigError,
71
88
  piConfigDir,
72
89
  switchbackStateDir,
73
- LOCAL_CLASSIFIER_APIS,
74
- resolveJevConfig,
90
+ resolveDecisionModel,
75
91
  resolveSecretApiKey,
76
92
  validateFileConfig,
77
93
  } from "./config.ts";
78
- export type { SwitchbackConfig, SwitchbackFileConfig, JevConfig, SecretResolver } from "./config.ts";
79
- export type { JevRef, DecisionModelEntry, ResolvedSwitchbackConfig, ResolvedSwitchbackFileConfig } from "./types.ts";
94
+ export type { SwitchbackConfig, SwitchbackFileConfig, DecisionModelConfig, SecretResolver } from "./config.ts";
95
+ export type {
96
+ DecisionModelEntry,
97
+ DecisionModelId,
98
+ ResolvedSwitchbackConfig,
99
+ ResolvedSwitchbackFileConfig,
100
+ } from "./types.ts";
80
101
  export {
81
102
  addDecisionModel,
82
103
  addFallback,
@@ -99,6 +120,7 @@ export {
99
120
  setDecisionModel,
100
121
  setDebug,
101
122
  setFallbacks,
123
+ setIdleReset,
102
124
  setVirtualModelName,
103
125
  updateDecisionModel,
104
126
  } from "./config-editor.ts";
@@ -133,6 +155,7 @@ export type { AvailabilityPort } from "./ports.ts";
133
155
  export type {
134
156
  Availability,
135
157
  BlockedMap,
158
+ DisabledMap,
136
159
  ClassifiedError,
137
160
  ErrorClass,
138
161
  ErrorScope,
package/src/routing.ts CHANGED
@@ -67,12 +67,26 @@ import type { NoClassifierReason } from "./classify.ts";
67
67
  import { chooseContextCandidate, fitsContext } from "./context-fit.ts";
68
68
  import { assessFailure, recordDecideCrash } from "./failure.ts";
69
69
  import { decideIdleReset } from "./idle.ts";
70
- import { getPinnedModel, isBlocked, unblockModel } from "./state.ts";
71
- import type { BlockedMap, JevConfig, ModelId, ResolvedSwitchbackConfig, SwitchbackState, ErrorScope } from "./types.ts";
70
+ import { getPinnedModel, isBlocked, isDisabled, unblockModel } from "./state.ts";
71
+ import type {
72
+ BlockedMap,
73
+ DisabledMap,
74
+ DecisionModelConfig,
75
+ ModelId,
76
+ ResolvedSwitchbackConfig,
77
+ SwitchbackState,
78
+ ErrorScope,
79
+ } from "./types.ts";
72
80
 
73
81
  export interface RouteInputs {
74
82
  now: number;
75
83
  blocked: BlockedMap;
84
+ /**
85
+ * Models the user disabled by hand (`/switchback-disable`). Unlike `blocked`
86
+ * these never expire; a disabled model is skipped in every chain and never
87
+ * chosen as a pin or continuation target.
88
+ */
89
+ disabled: DisabledMap;
76
90
  /** The host's reasoning-level rules; see `packages/core/src/ports.ts`. */
77
91
  support: ThinkingSupportPort;
78
92
  /** Optional simulate-mode error message injected for `retry` requests. */
@@ -154,10 +168,11 @@ async function pickWithContextFit(opts: {
154
168
  skip: ModelId | undefined;
155
169
  excluded?: ReadonlySet<ModelId>;
156
170
  blocked: BlockedMap;
171
+ disabled: DisabledMap;
157
172
  now: number;
158
173
  startAfter: ModelId | undefined;
159
174
  registry: RouterRegistryPort;
160
- jev: JevConfig | undefined;
175
+ decisionModel: DecisionModelConfig | undefined;
161
176
  contextTokens: number | undefined;
162
177
  reason?: string;
163
178
  failedScope?: ErrorScope | undefined;
@@ -165,7 +180,7 @@ async function pickWithContextFit(opts: {
165
180
  const excluded = opts.excluded ?? new Set<ModelId>();
166
181
  const usable = walkOrder(opts.resolved, opts.startAfter)
167
182
  .filter((id) => id !== opts.skip && !excluded.has(id))
168
- .filter((id) => !isBlocked(id, opts.now, opts.blocked));
183
+ .filter((id) => !isBlocked(id, opts.now, opts.blocked) && !isDisabled(id, opts.disabled));
169
184
  let eligible = usable
170
185
  .map((id) => ({ id, model: lookupModel(opts.registry, id) }))
171
186
  .filter((entry): entry is { id: ModelId; model: HostModel } => entry.model !== undefined);
@@ -177,7 +192,15 @@ async function pickWithContextFit(opts: {
177
192
  if (fitting.length > 0) eligible = fitting;
178
193
  }
179
194
  if (eligible.length === 0)
180
- return pickNextEffective(opts.resolved, opts.skip, opts.blocked, opts.now, opts.startAfter, excluded);
195
+ return pickNextEffective(
196
+ opts.resolved,
197
+ opts.skip,
198
+ opts.blocked,
199
+ opts.now,
200
+ opts.startAfter,
201
+ excluded,
202
+ opts.disabled,
203
+ );
181
204
  const failedProvider = opts.skip === undefined ? undefined : providerPart(opts.skip);
182
205
  const ordered = orderByScope(
183
206
  eligible.map((entry) => entry.id),
@@ -188,7 +211,7 @@ async function pickWithContextFit(opts: {
188
211
  if (ordered.length === 1) return { modelId: floor, degraded: false };
189
212
  const choice = await chooseContextCandidate({
190
213
  registry: opts.registry,
191
- jev: opts.jev,
214
+ decisionModel: opts.decisionModel,
192
215
  preferred: floor,
193
216
  contextTokens: tokens ?? 0,
194
217
  candidates: ordered.map((id) => {
@@ -227,7 +250,7 @@ function orderByScope(
227
250
  function describeNoClassifier(reason: NoClassifierReason): string {
228
251
  switch (reason) {
229
252
  case "not-configured":
230
- return "no classifier configured (set jev: in switchback.yaml)";
253
+ return "no classifier configured (set decisionModel: in switchback.yaml)";
231
254
  case "unresolvable":
232
255
  return "configured classifier not found in registry";
233
256
  case "timeout":
@@ -282,7 +305,8 @@ export async function decide(
282
305
  if (
283
306
  previousId &&
284
307
  resolved.entries.some((e) => e.id === previousId && e.availability === "effective") &&
285
- !isBlocked(previousId, now, blocked)
308
+ !isBlocked(previousId, now, blocked) &&
309
+ !isDisabled(previousId, inputs.disabled)
286
310
  ) {
287
311
  return {
288
312
  decision: { kind: "stick", modelId: previousId, reason: "continuation" },
@@ -302,7 +326,11 @@ export async function decide(
302
326
  const pinned = getPinnedModel(modelConfig.id);
303
327
  if (pinned !== undefined) {
304
328
  const pin = resolved.entries.find((e) => e.id === pinned);
305
- if (pin?.availability === "effective" && !isBlocked(pinned, now, blocked)) {
329
+ if (
330
+ pin?.availability === "effective" &&
331
+ !isBlocked(pinned, now, blocked) &&
332
+ !isDisabled(pinned, inputs.disabled)
333
+ ) {
306
334
  return resolveDispatchLevel(
307
335
  {
308
336
  decision:
@@ -330,7 +358,7 @@ export async function decide(
330
358
  // above still outranks it (it is the user's explicit override).
331
359
  const idle = await decideIdleReset({
332
360
  registry,
333
- jev: modelConfig.jev,
361
+ decisionModel: modelConfig.decisionModel,
334
362
  option: modelConfig.idleReset,
335
363
  messages: request.messages,
336
364
  now,
@@ -345,10 +373,11 @@ export async function decide(
345
373
  resolved,
346
374
  skip: undefined,
347
375
  blocked,
376
+ disabled: inputs.disabled,
348
377
  now,
349
378
  startAfter: undefined,
350
379
  registry,
351
- jev: modelConfig.jev,
380
+ decisionModel: modelConfig.decisionModel,
352
381
  reason: "idle-reset",
353
382
  contextTokens: inputs.contextTokens,
354
383
  });
@@ -381,7 +410,11 @@ export async function decide(
381
410
  // current model is left behind, and the walk continues FORWARD from it.
382
411
  if (current !== undefined) {
383
412
  const cur = resolved.entries.find((e) => e.id === current);
384
- if (cur?.availability === "effective" && !isBlocked(current, now, blocked)) {
413
+ if (
414
+ cur?.availability === "effective" &&
415
+ !isBlocked(current, now, blocked) &&
416
+ !isDisabled(current, inputs.disabled)
417
+ ) {
385
418
  return {
386
419
  decision: { kind: "stick", modelId: current, reason: "session-sticky" },
387
420
  nextState: keepState(current, request.state, request.state?.transientRetries ?? 0),
@@ -396,10 +429,11 @@ export async function decide(
396
429
  resolved,
397
430
  skip: undefined,
398
431
  blocked,
432
+ disabled: inputs.disabled,
399
433
  now,
400
434
  startAfter: current,
401
435
  registry,
402
- jev: modelConfig.jev,
436
+ decisionModel: modelConfig.decisionModel,
403
437
  contextTokens: inputs.contextTokens,
404
438
  });
405
439
  if (!pick) {
@@ -470,10 +504,11 @@ async function decideRetry(
470
504
  resolved,
471
505
  skip: undefined,
472
506
  blocked,
507
+ disabled: inputs.disabled,
473
508
  now,
474
509
  startAfter: advanceFrom,
475
510
  registry,
476
- jev: modelConfig.jev,
511
+ decisionModel: modelConfig.decisionModel,
477
512
  contextTokens: inputs.contextTokens,
478
513
  });
479
514
  if (!pick) {
@@ -493,7 +528,7 @@ async function decideRetry(
493
528
  failedId,
494
529
  errorMessage,
495
530
  stopReason,
496
- jev: modelConfig.jev,
531
+ decisionModel: modelConfig.decisionModel,
497
532
  registry,
498
533
  now,
499
534
  blocked,
@@ -521,10 +556,11 @@ async function decideRetry(
521
556
  excluded: retryExcluded,
522
557
  failedScope: classified?.scope,
523
558
  blocked,
559
+ disabled: inputs.disabled,
524
560
  now,
525
561
  startAfter: advanceFrom,
526
562
  registry,
527
- jev: modelConfig.jev,
563
+ decisionModel: modelConfig.decisionModel,
528
564
  contextTokens: inputs.contextTokens,
529
565
  });
530
566
  recordDecideCrash(failedId, registry, errorMessage, now, null, result.reason, "blind-cycle");
@@ -581,10 +617,11 @@ async function decideRetry(
581
617
  excluded: retryExcluded,
582
618
  failedScope: classified?.scope,
583
619
  blocked,
620
+ disabled: inputs.disabled,
584
621
  now,
585
622
  startAfter: advanceFrom,
586
623
  registry,
587
- jev: modelConfig.jev,
624
+ decisionModel: modelConfig.decisionModel,
588
625
  contextTokens: inputs.contextTokens,
589
626
  });
590
627
  recordDecideCrash(
@@ -625,10 +662,11 @@ async function decideRetry(
625
662
  excluded: retryExcluded,
626
663
  failedScope: classified?.scope,
627
664
  blocked,
665
+ disabled: inputs.disabled,
628
666
  now,
629
667
  startAfter: advanceFrom,
630
668
  registry,
631
- jev: modelConfig.jev,
669
+ decisionModel: modelConfig.decisionModel,
632
670
  contextTokens: inputs.contextTokens,
633
671
  });
634
672
  recordDecideCrash(
package/src/simulate.ts CHANGED
@@ -30,7 +30,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
30
30
  import type { Decision } from "./decision.ts";
31
31
  import type { NotifyPort, RouterRegistryPort, ThinkingSupportPort } from "./ports.ts";
32
32
  import { decide } from "./routing.ts";
33
- import type { BlockedMap, ModelId, ResolvedSwitchbackConfig } from "./types.ts";
33
+ import type { BlockedMap, DisabledMap, ModelId, ResolvedSwitchbackConfig } from "./types.ts";
34
34
 
35
35
  export interface SimulateConfig {
36
36
  scenarios: Record<string, string>;
@@ -112,10 +112,17 @@ export async function simulateRetry(
112
112
  scenarioMessage: string,
113
113
  modelConfig: ResolvedSwitchbackConfig,
114
114
  registry: RouterRegistryPort,
115
- inputs: { support: ThinkingSupportPort; now?: number; blocked?: BlockedMap; notify?: NotifyPort },
115
+ inputs: {
116
+ support: ThinkingSupportPort;
117
+ now?: number;
118
+ blocked?: BlockedMap;
119
+ disabled?: DisabledMap;
120
+ notify?: NotifyPort;
121
+ },
116
122
  ): Promise<SimulateResult> {
117
123
  const now = inputs.now ?? Date.now();
118
124
  const blocked = inputs.blocked ?? {};
125
+ const disabled = inputs.disabled ?? {};
119
126
  const firstFallback = modelConfig.fallbacks[0];
120
127
  if (firstFallback === undefined) throw new SimulateError("model config has no fallbacks");
121
128
  const [provider, id] = firstFallback.split("/");
@@ -167,6 +174,7 @@ export async function simulateRetry(
167
174
  const result = await decide("retry", fakeRequest, modelConfig, registry, {
168
175
  now,
169
176
  blocked,
177
+ disabled,
170
178
  support: inputs.support,
171
179
  simulateErrorMessage: scenarioMessage,
172
180
  ...(inputs.notify ? { notify: inputs.notify } : {}),
package/src/state.ts CHANGED
@@ -20,7 +20,8 @@ import { existsSync, readFileSync, rmSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { writeFileAtomic } from "./atomic-write.ts";
22
22
  import { switchbackStateDir } from "./config.ts";
23
- import type { BlockedMap, ModelId } from "./types.ts";
23
+ import type { BlockedMap, DisabledMap, ModelId } from "./types.ts";
24
+ export type { DisabledMap };
24
25
 
25
26
  /** Path to the blocked-until map. The new (account-scoped) location. */
26
27
  export function stateFilePath(): string {
@@ -122,6 +123,67 @@ export function unblockModel(modelId: ModelId, path: string = stateFilePath()):
122
123
  writeRaw(path, map);
123
124
  }
124
125
 
126
+ /**
127
+ * Manually disabled models, persisted to `<state dir>/disabled.json`.
128
+ *
129
+ * This is the user's explicit "never route here" list, set and cleared by the
130
+ * `/switchback-disable` and `/switchback-enable` commands. Unlike blocks it has
131
+ * no reset time: a disabled model stays out of every chain until it is enabled
132
+ * again. It lives in the state dir (not switchback.yaml) so regenerating the
133
+ * config - for example re-running the switchback-free skill - cannot silently
134
+ * re-enable a model the user turned off. Values are the epoch-ms disable time,
135
+ * kept so a status display can say since when.
136
+ */
137
+ /** Path to the manually-disabled map. */
138
+ export function disabledFilePath(): string {
139
+ return join(switchbackStateDir(), "disabled.json");
140
+ }
141
+
142
+ function readDisabledRaw(path: string): DisabledMap {
143
+ return readRaw(path);
144
+ }
145
+
146
+ function writeDisabledRaw(path: string, map: DisabledMap): void {
147
+ writeRaw(path, map);
148
+ }
149
+
150
+ /** Read the manually-disabled map. Entries never expire; only `enableModel` removes them. */
151
+ export function readDisabledMap(path: string = disabledFilePath()): DisabledMap {
152
+ return readDisabledRaw(path);
153
+ }
154
+
155
+ /** Check whether a model was manually disabled. Takes the map: callers read it once per decision. */
156
+ export function isDisabled(modelId: ModelId, map: DisabledMap): boolean {
157
+ return map[modelId] !== undefined;
158
+ }
159
+
160
+ /** Disable a model. Returns false when it was already disabled. */
161
+ export function disableModel(modelId: ModelId, now: number = Date.now(), path: string = disabledFilePath()): boolean {
162
+ const map = readDisabledRaw(path);
163
+ if (map[modelId] !== undefined) return false;
164
+ map[modelId] = now;
165
+ writeDisabledRaw(path, map);
166
+ return true;
167
+ }
168
+
169
+ /** Enable a model again. Returns false when it was not disabled. */
170
+ export function enableModel(modelId: ModelId, path: string = disabledFilePath()): boolean {
171
+ const map = readDisabledRaw(path);
172
+ if (!(modelId in map)) return false;
173
+ delete map[modelId];
174
+ writeDisabledRaw(path, map);
175
+ return true;
176
+ }
177
+
178
+ /** Clear the whole list (the `/switchback-enable all` path). Returns how many were removed. */
179
+ export function clearDisabled(path: string = disabledFilePath()): number {
180
+ const map = readDisabledRaw(path);
181
+ const count = Object.keys(map).length;
182
+ if (count === 0) return 0;
183
+ writeDisabledRaw(path, {});
184
+ return count;
185
+ }
186
+
125
187
  /**
126
188
  * Manually pinned model per virtual model, persisted to
127
189
  * `<piConfigDir>/switchback/pin.json`. A pin is the user's explicit "route here"
package/src/systemone.ts CHANGED
@@ -1,24 +1,26 @@
1
1
  /**
2
- * Minimal System One classifier transport.
2
+ * System One classifier transport, delegated to the official SDK
3
+ * (`@typesafe-ai/sdk`): URL building, auth, retries, backoff, abort and body
4
+ * encoding are the package's; switchback only picks the endpoint, maps the
5
+ * question/answer types it already shares with pi-ai, and keeps its own
6
+ * fail-closed answer validation (the SDK types the reply but does not
7
+ * runtime-validate it).
3
8
  *
4
- * Why this exists instead of importing pi's own transport:
5
- * `@earendil-works/pi-ai/api/typesafe-system-one.lazy` resolves in this
6
- * repository (it has its own node_modules) but NOT in a package installed by
7
- * `pi install`. Pi supplies host packages to extensions by *bare specifier*
8
- * only, so `import ... from "@earendil-works/pi-ai"` works while any deep
9
- * subpath fails with "Cannot find module". The type-only imports below are
10
- * erased at load, so they are safe; a value import is not.
9
+ * Why not pi's own transport: `@earendil-works/pi-ai/api/typesafe-system-one.lazy`
10
+ * resolves in this repository but NOT in a package installed by `pi install` -
11
+ * pi supplies host packages to extensions by *bare specifier* only, so any deep
12
+ * subpath fails with "Cannot find module". The type-only pi-ai imports below
13
+ * are erased at load; the SDK is switchback-core's own real dependency and
14
+ * resolves everywhere.
11
15
  *
12
- * The wire protocol is small and stable (Ollama v0.35+ and TypeSafe both serve
13
- * it): POST `${baseUrl}/systemone` with `{ model, state, questions }`, read
14
- * `{ answers }`. `bool` questions travel as the wire-level `noul` type.
15
- *
16
- * This is switchback's own adapter for its own opt-in local-classifier
17
- * endpoint. It deliberately does not reimplement pi's retry/usage-costing
18
- * layers: a local server needs neither, and classifyError already applies its
19
- * own timeout and treats a transport error as "no classifier".
16
+ * URL conventions differ and are bridged in `toSdkBaseUrl`: switchback (and
17
+ * pi, and OpenAI-compatible servers) carry the version segment in `baseUrl`
18
+ * (`http://host:11434/v1` + `/systemone`), while the SDK takes an API root and
19
+ * appends `/v1/systemone` itself.
20
20
  */
21
21
 
22
+ import { TypeSafeClient } from "@typesafe-ai/sdk";
23
+ import type { EntryType, Fetch, Questions } from "@typesafe-ai/sdk";
22
24
  import type {
23
25
  ClassifierAnswer,
24
26
  ClassifierApi,
@@ -52,19 +54,11 @@ export function classifierState(text: string): ClassifierContext["state"] {
52
54
  return text.slice(0, 16_000) as unknown as ClassifierContext["state"];
53
55
  }
54
56
 
55
- /** Default request timeout. classifyError wraps the call in its own race (see `CLASSIFIER_TIMEOUT_MS`). */
57
+ /** Per-attempt timeout handed to the SDK; classifyError wraps the call in its own race (see `CLASSIFIER_TIMEOUT_MS`). */
56
58
  const REQUEST_TIMEOUT_MS = 30_000;
57
59
 
58
- /** The subset of `fetch` this transport uses, injectable for tests. */
59
- export type FetchLike = (
60
- url: string,
61
- init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
62
- ) => Promise<{
63
- ok: boolean;
64
- status: number;
65
- text(): Promise<string>;
66
- json(): Promise<unknown>;
67
- }>;
60
+ /** The fetch implementation the SDK uses; injectable for tests. Alias of the SDK's own `Fetch`. */
61
+ export type FetchLike = Fetch;
68
62
 
69
63
  export interface SystemOneOptions {
70
64
  /** Bearer token. Local servers ignore it, but the header is always sent. */
@@ -85,6 +79,17 @@ function wireQuestions(questions: Record<string, ClassifierQuestion>): Record<st
85
79
  );
86
80
  }
87
81
 
82
+ /**
83
+ * Translate switchback's `baseUrl` (version segment included, pi/OpenAI
84
+ * compatible convention) into the SDK's API root (version segment appended by
85
+ * the SDK): `http://host:11434/v1` -> `http://host:11434`, so the SDK's
86
+ * `/v1/systemone` lands on the same URL the old transport built.
87
+ */
88
+ function toSdkBaseUrl(baseUrl: string): string {
89
+ const trimmed = baseUrl.replace(/\/+$/, "");
90
+ return trimmed.endsWith("/v1") ? trimmed.slice(0, -"/v1".length) : trimmed;
91
+ }
92
+
88
93
  function isRecord(value: unknown): value is Record<string, unknown> {
89
94
  return typeof value === "object" && value !== null && !Array.isArray(value);
90
95
  }
@@ -143,7 +148,6 @@ function parseAnswers(label: string, value: unknown, context: ClassifierContext)
143
148
  */
144
149
  export function systemOneClassifier(baseUrl: string, options: SystemOneOptions = {}): ProviderClassifier {
145
150
  const label = options.label ?? "System One";
146
- const url = `${baseUrl.replace(/\/+$/, "")}/systemone`;
147
151
  return {
148
152
  async classify(
149
153
  model: ClassifierModel<ClassifierApi>,
@@ -161,24 +165,26 @@ export function systemOneClassifier(baseUrl: string, options: SystemOneOptions =
161
165
  try {
162
166
  const apiKey = classifierOptions?.apiKey ?? options.apiKey;
163
167
  if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
164
- const doFetch = options.fetch ?? (globalThis.fetch as unknown as FetchLike);
165
- const signal = classifierOptions?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS);
166
- const response = await doFetch(url, {
167
- method: "POST",
168
- headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
169
- body: JSON.stringify({
170
- model: model.id,
171
- state: context.state,
172
- questions: wireQuestions(context.questions),
173
- }),
174
- signal,
168
+ const client = new TypeSafeClient({
169
+ apiKey,
170
+ baseURL: toSdkBaseUrl(baseUrl),
171
+ // Local SystemOne servers (Ollama, sham gateways) do not accept
172
+ // `Content-Encoding: zstd`, which the SDK defaults to on Node >= 22.15.
173
+ compression: false,
174
+ ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
175
175
  });
176
- const bodyText = response.ok ? "" : await response.text();
177
- if (!response.ok)
178
- throw new Error(`${label} returned ${response.status}${bodyText ? `: ${bodyText}` : ""}`);
179
- const body: unknown = await response.json();
180
- if (!isRecord(body)) throw new Error(`${label} returned an unexpected response`);
181
- result.answers = parseAnswers(label, body["answers"], context);
176
+ const reply = await client.systemOne(
177
+ {
178
+ model: model.id,
179
+ state: context.state as unknown as NonNullable<EntryType>,
180
+ questions: wireQuestions(context.questions) as unknown as Questions,
181
+ },
182
+ {
183
+ signal: classifierOptions?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
184
+ timeout: REQUEST_TIMEOUT_MS,
185
+ },
186
+ );
187
+ result.answers = parseAnswers(label, reply.answers, context);
182
188
  return result;
183
189
  } catch (error) {
184
190
  result.stopReason = classifierOptions?.signal?.aborted ? "aborted" : "error";
@@ -198,7 +204,7 @@ export function systemOneClassifier(baseUrl: string, options: SystemOneOptions =
198
204
  export interface SystemOneClassifierPortOptions {
199
205
  /** Base URL of the System One endpoint; `undefined` matches "not configured". */
200
206
  baseUrl: string | undefined;
201
- /** The provider/id pair `findOfType` resolves - pass the config's `jev` identity. */
207
+ /** The provider/id pair `findOfType` resolves - pass the config's `decisionModel` identity. */
202
208
  identity: { provider: string; id: string };
203
209
  /** Bearer token for the endpoint. */
204
210
  apiKey?: string;
package/src/thinking.ts CHANGED
@@ -116,7 +116,7 @@ export interface ThinkingLevelChoice {
116
116
  export async function chooseThinkingLevel(opts: {
117
117
  registry: ClassifierPort;
118
118
  support: ThinkingSupportPort;
119
- jev: { provider: string; id: string } | undefined;
119
+ decisionModel: { provider: string; id: string } | undefined;
120
120
  model: HostModel;
121
121
  requested: ThinkingLevelId;
122
122
  context: ThinkingLevelContext;
@@ -144,7 +144,7 @@ export async function chooseThinkingLevel(opts: {
144
144
  failover: opts.context.failover ? "yes" : "no",
145
145
  };
146
146
 
147
- const call = await callClassifier(opts.registry, opts.jev, {
147
+ const call = await callClassifier(opts.registry, opts.decisionModel, {
148
148
  state,
149
149
  questions: { level: criteria },
150
150
  });