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 +2 -1
- package/src/availability.ts +5 -4
- package/src/classify.ts +14 -11
- package/src/config-editor.ts +101 -107
- package/src/config.ts +202 -183
- package/src/context-fit.ts +3 -3
- package/src/dispatch.ts +1 -1
- package/src/failure.ts +6 -6
- package/src/idle.ts +3 -3
- package/src/index.ts +28 -5
- package/src/routing.ts +56 -18
- package/src/simulate.ts +10 -2
- package/src/state.ts +63 -1
- package/src/systemone.ts +52 -46
- package/src/thinking.ts +2 -2
- package/src/types.ts +56 -31
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 {
|
|
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
|
-
|
|
74
|
-
resolveJevConfig,
|
|
90
|
+
resolveDecisionModel,
|
|
75
91
|
resolveSecretApiKey,
|
|
76
92
|
validateFileConfig,
|
|
77
93
|
} from "./config.ts";
|
|
78
|
-
export type { SwitchbackConfig, SwitchbackFileConfig,
|
|
79
|
-
export type {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
*
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
147
|
+
const call = await callClassifier(opts.registry, opts.decisionModel, {
|
|
148
148
|
state,
|
|
149
149
|
questions: { level: criteria },
|
|
150
150
|
});
|