switchback-core 0.0.0-stage → 2026.10.15
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 +50 -5
- package/src/atomic-write.ts +96 -0
- package/src/availability.ts +151 -0
- package/src/classifier-catalog.ts +129 -0
- package/src/classifier-discovery.ts +113 -0
- package/src/classifier-probe.ts +225 -0
- package/src/classify.ts +460 -0
- package/src/config-editor.ts +429 -0
- package/src/config.ts +634 -0
- package/src/context-fit.ts +165 -0
- package/src/crashes.ts +271 -0
- package/src/decision.ts +32 -0
- package/src/dispatch.ts +97 -0
- package/src/failure.ts +228 -0
- package/src/idle.ts +242 -0
- package/src/index.ts +162 -0
- package/src/ports.ts +107 -0
- package/src/routing.ts +708 -0
- package/src/simulate.ts +183 -0
- package/src/state.ts +239 -0
- package/src/systemone.ts +268 -0
- package/src/thinking.ts +166 -0
- package/src/types.ts +178 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "switchback-core",
|
|
3
|
+
"version": "2026.10.15",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Host-agnostic core for switchback: fallback routing, error classification ports, block state and configuration.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jr2804/switchback.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/jr2804/switchback#readme",
|
|
13
|
+
"bugs": "https://github.com/jr2804/switchback/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"switchback",
|
|
16
|
+
"fallback",
|
|
17
|
+
"quota",
|
|
18
|
+
"routing",
|
|
19
|
+
"llm"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts",
|
|
26
|
+
"./*": "./src/*"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@typesafe-ai/sdk": "^0.7.0",
|
|
33
|
+
"yaml": "^2.9.1"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-ai": "*",
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@earendil-works/pi-ai": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"@earendil-works/pi-coding-agent": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "^1.1.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "^1.1.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one atomic file writer behind every switchback store.
|
|
3
|
+
*
|
|
4
|
+
* A store is written to a temp file in the destination directory and then
|
|
5
|
+
* renamed over the destination, so a reader (another pi session, a crashed
|
|
6
|
+
* run's leftovers) never sees a half-written file.
|
|
7
|
+
*
|
|
8
|
+
* Two things the shape has to get right on Windows, where the rename is not
|
|
9
|
+
* atomic for free:
|
|
10
|
+
*
|
|
11
|
+
* - **The temp name is per write, not per file.** A fixed `<path>.tmp` makes
|
|
12
|
+
* two writers in one directory (two pi sessions sharing
|
|
13
|
+
* `<piConfigDir>/switchback/`) fight over the same staging file: whichever
|
|
14
|
+
* renames first moves the *other's* bytes away, and the loser then fails on a
|
|
15
|
+
* source that no longer exists.
|
|
16
|
+
* - **The rename is retried with a real backoff.** While anything else holds
|
|
17
|
+
* the destination - an indexer, an antivirus scanner, another process - Windows
|
|
18
|
+
* refuses the rename and Node reports `EPERM`. That window is short, so an
|
|
19
|
+
* immediate retry loses the race: with two back-to-back attempts the crash
|
|
20
|
+
* store failed about one full test run in three (measured 2026-10-09), and the
|
|
21
|
+
* retry that existed then slept zero milliseconds between attempts. The budget
|
|
22
|
+
* below is 10…320 ms.
|
|
23
|
+
*
|
|
24
|
+
* The callers own their own error vocabulary (`CrashError`, `SecretsError`,
|
|
25
|
+
* `ConfigError`); this module reports a failure as `AtomicWriteError` with the
|
|
26
|
+
* last filesystem error as its `cause`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
|
|
32
|
+
/** Rename attempts before a write is declared failed: 6 backoffs, 10…320 ms. */
|
|
33
|
+
const RENAME_ATTEMPTS = 7;
|
|
34
|
+
|
|
35
|
+
/** First backoff; each further attempt doubles it (10, 20, 40, 80, 160, 320). */
|
|
36
|
+
const RENAME_BACKOFF_MS = 10;
|
|
37
|
+
|
|
38
|
+
/** Per-process counter, so two writes in one process never share a temp path. */
|
|
39
|
+
let tmpSeq = 0;
|
|
40
|
+
|
|
41
|
+
/** A store write that could not be completed; `cause` is the last fs error. */
|
|
42
|
+
export class AtomicWriteError extends Error {
|
|
43
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
44
|
+
super(`switchback: atomic: ${message}`, options);
|
|
45
|
+
this.name = "AtomicWriteError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The staging path the next write to `path` uses. Distinct on every call. */
|
|
50
|
+
export function atomicTmpPath(path: string): string {
|
|
51
|
+
tmpSeq += 1;
|
|
52
|
+
return `${path}.${process.pid}.${tmpSeq}.tmp`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write `contents` to `path` atomically, creating the parent directory if
|
|
57
|
+
* needed. Throws {@link AtomicWriteError} - and leaves no temp file behind -
|
|
58
|
+
* when the rename still fails after the retry budget.
|
|
59
|
+
*/
|
|
60
|
+
export function writeFileAtomic(path: string, contents: string): void {
|
|
61
|
+
const tmp = atomicTmpPath(path);
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
64
|
+
writeFileSync(tmp, contents, "utf8");
|
|
65
|
+
let lastError: unknown = undefined;
|
|
66
|
+
for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
renameSync(tmp, path);
|
|
69
|
+
return;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
lastError = error;
|
|
72
|
+
if (attempt < RENAME_ATTEMPTS - 1) sleep(RENAME_BACKOFF_MS * 2 ** attempt);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new AtomicWriteError(`could not rename ${tmp} onto ${path} after ${RENAME_ATTEMPTS} attempts`, {
|
|
76
|
+
cause: lastError,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Cleanup is best-effort: with a broken parent (a file where a directory
|
|
80
|
+
// should be) rmSync itself throws ENOTDIR on POSIX, and letting that
|
|
81
|
+
// escape would mask the write failure this block exists to report.
|
|
82
|
+
try {
|
|
83
|
+
rmSync(tmp, { force: true });
|
|
84
|
+
} catch {
|
|
85
|
+
// nothing to remove - the staging file was never created
|
|
86
|
+
}
|
|
87
|
+
throw error instanceof AtomicWriteError
|
|
88
|
+
? error
|
|
89
|
+
: new AtomicWriteError(`could not write ${path}`, { cause: error });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Block the calling thread. Stores are written from sync code, so there is no loop to await. */
|
|
94
|
+
function sleep(ms: number): void {
|
|
95
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
96
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-route availability resolution.
|
|
3
|
+
*
|
|
4
|
+
* Resolves every entry in the user's configured fallback list against the
|
|
5
|
+
* current pi model registry. Each entry is classified as:
|
|
6
|
+
* - effective - the model is in the catalog AND its provider has working credentials.
|
|
7
|
+
* - greyed-model-not-in-catalog - the catalog no longer lists this model id.
|
|
8
|
+
* - greyed-provider-unavailable - the provider is not credentialed in the current pi session.
|
|
9
|
+
*
|
|
10
|
+
* Resolution is per-call (no caching) so an entry that reappears after the user
|
|
11
|
+
* fixes their setup becomes effective again with no config edit. Greyed entries
|
|
12
|
+
* are NEVER written to the account-scoped block map
|
|
13
|
+
* (`<piConfigDir>/switchback/blocks.json`); they are an availability concern,
|
|
14
|
+
* not a quota concern.
|
|
15
|
+
*
|
|
16
|
+
* The user config is the single source of truth for which entries to consider;
|
|
17
|
+
* we never silently drop entries. Removing a greyed entry from the config is
|
|
18
|
+
* an explicit user action.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AvailabilityPort, HostModel } from "./ports.ts";
|
|
22
|
+
import type { Availability, BlockedMap, DisabledMap, ModelId, ResolvedEntry, ResolvedFallbacks } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
function splitModelId(modelId: ModelId): { provider: string; id: string } | undefined {
|
|
25
|
+
const slash = modelId.indexOf("/");
|
|
26
|
+
if (slash <= 0 || slash === modelId.length - 1) return undefined;
|
|
27
|
+
return { provider: modelId.slice(0, slash), id: modelId.slice(slash + 1) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The named physical model, when the registry knows it. Generic in the host's model type. */
|
|
31
|
+
export function lookupModel<M extends HostModel>(
|
|
32
|
+
registry: { find(provider: string, modelId: string): M | undefined },
|
|
33
|
+
modelId: ModelId,
|
|
34
|
+
): M | undefined {
|
|
35
|
+
const split = splitModelId(modelId);
|
|
36
|
+
if (split === undefined) return undefined;
|
|
37
|
+
return registry.find(split.provider, split.id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve every configured fallback against the registry. Returns the full list
|
|
42
|
+
* (effective + greyed) plus counts. Pure apart from the registry read.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveFallbacks(fallbacks: readonly ModelId[], registry: AvailabilityPort): ResolvedFallbacks {
|
|
45
|
+
const entries: ResolvedEntry[] = [];
|
|
46
|
+
let effectiveCount = 0;
|
|
47
|
+
let greyedCount = 0;
|
|
48
|
+
for (const modelId of fallbacks) {
|
|
49
|
+
const split = splitModelId(modelId);
|
|
50
|
+
if (split === undefined) {
|
|
51
|
+
entries.push({ id: modelId, availability: "greyed-model-not-in-catalog", reason: "invalid-id-format" });
|
|
52
|
+
greyedCount += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const model = registry.find(split.provider, split.id);
|
|
56
|
+
if (!model) {
|
|
57
|
+
entries.push({ id: modelId, availability: "greyed-model-not-in-catalog", reason: "model-not-in-catalog" });
|
|
58
|
+
greyedCount += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const authStatus = registry.getProviderAuthStatus(split.provider);
|
|
62
|
+
if (!authStatus.configured) {
|
|
63
|
+
entries.push({
|
|
64
|
+
id: modelId,
|
|
65
|
+
availability: "greyed-provider-unavailable",
|
|
66
|
+
reason: authStatus.label ?? "no-credentials",
|
|
67
|
+
});
|
|
68
|
+
greyedCount += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!registry.hasConfiguredAuth(model)) {
|
|
72
|
+
entries.push({
|
|
73
|
+
id: modelId,
|
|
74
|
+
availability: "greyed-provider-unavailable",
|
|
75
|
+
reason: "model-not-credentialed",
|
|
76
|
+
});
|
|
77
|
+
greyedCount += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
entries.push({ id: modelId, availability: "effective" });
|
|
81
|
+
effectiveCount += 1;
|
|
82
|
+
}
|
|
83
|
+
return { entries, effectiveCount, greyedCount };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Return the list of effective model ids in their configured order. */
|
|
87
|
+
export function effectiveIds(resolved: ResolvedFallbacks): ModelId[] {
|
|
88
|
+
const out: ModelId[] = [];
|
|
89
|
+
for (const entry of resolved.entries) {
|
|
90
|
+
if (entry.availability === "effective") out.push(entry.id);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Return a single greyed entry's reason string, or undefined when not greyed. */
|
|
96
|
+
export function greyedReason(entry: ResolvedEntry | undefined): string | undefined {
|
|
97
|
+
if (!entry || entry.availability === "effective") return undefined;
|
|
98
|
+
return entry.reason;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pick the next effective, non-blocked entry.
|
|
103
|
+
*
|
|
104
|
+
* The walk is ordered and forward-moving, which is what makes a chain of failures
|
|
105
|
+
* advance through the list instead of bouncing between its first two entries:
|
|
106
|
+
*
|
|
107
|
+
* - `startAfter` names the entry to continue past (the model that just failed, or
|
|
108
|
+
* the session's current model when it is no longer usable). The walk starts at
|
|
109
|
+
* the entry after it in configured order and wraps around once. Without
|
|
110
|
+
* `startAfter` the walk starts at the head of the list.
|
|
111
|
+
* - `skip` is never returned (normally the same id as `startAfter`; they differ
|
|
112
|
+
* only where the router must not pick an id it is not advancing past).
|
|
113
|
+
*
|
|
114
|
+
* Degraded mode (returns the most-preferred effective entry even when blocked) is
|
|
115
|
+
* only allowed when there is exactly one effective entry. With multiple effective
|
|
116
|
+
* entries, every-blocked returns undefined so the router surfaces an explicit
|
|
117
|
+
* "exhausted" decision - a failing-provider response is NOT preferable to an
|
|
118
|
+
* explicit "all blocked" signal when there are still options.
|
|
119
|
+
*/
|
|
120
|
+
export function pickNextEffective(
|
|
121
|
+
resolved: ResolvedFallbacks,
|
|
122
|
+
skip: ModelId | undefined,
|
|
123
|
+
blocked: BlockedMap,
|
|
124
|
+
now: number,
|
|
125
|
+
startAfter?: ModelId,
|
|
126
|
+
excluded?: ReadonlySet<ModelId>,
|
|
127
|
+
disabled?: DisabledMap,
|
|
128
|
+
): { modelId: ModelId; degraded: boolean } | undefined {
|
|
129
|
+
const effective = effectiveIds(resolved);
|
|
130
|
+
if (effective.length === 0) return undefined;
|
|
131
|
+
const count = effective.length;
|
|
132
|
+
const start = startAfter === undefined ? 0 : effective.indexOf(startAfter) + 1;
|
|
133
|
+
for (let i = 0; i < count; i++) {
|
|
134
|
+
const candidate = effective[(start + i) % count]!;
|
|
135
|
+
if (candidate === skip || excluded?.has(candidate) === true) continue;
|
|
136
|
+
if (disabled?.[candidate] !== undefined) continue;
|
|
137
|
+
const blockedUntil = blocked[candidate];
|
|
138
|
+
if (blockedUntil !== undefined && blockedUntil > now) continue;
|
|
139
|
+
return { modelId: candidate, degraded: false };
|
|
140
|
+
}
|
|
141
|
+
// Degraded mode: only when exactly one effective entry exists. Edge case #4.
|
|
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) {
|
|
144
|
+
return { modelId: effective[0]!, degraded: true };
|
|
145
|
+
}
|
|
146
|
+
// Multiple effective entries all blocked: return undefined so the router
|
|
147
|
+
// surfaces "exhausted" rather than silently degrading.
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type { Availability, ResolvedEntry, ResolvedFallbacks };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The classifier providers the config dialogue offers.
|
|
3
|
+
*
|
|
4
|
+
* Every entry is derived from pi at runtime: the model registry already knows
|
|
5
|
+
* which providers exist, what they are called, where they live and which
|
|
6
|
+
* classifier models each one ships. Switchback keeps **no** provider table of
|
|
7
|
+
* its own - not a provider id, not a display name, not a base URL, not a model
|
|
8
|
+
* id. Anything hardcoded here would drift the moment pi-ai's generated catalog
|
|
9
|
+
* moves, and pi-ai's `dist/providers/data/*.json` is a build artifact, not an
|
|
10
|
+
* extension point.
|
|
11
|
+
*
|
|
12
|
+
* An endpoint pi does not know (a local Ollama server, a self-hosted gateway)
|
|
13
|
+
* is not part of the offer: the wizard's "Other" escape hatch collects the
|
|
14
|
+
* provider id and base URL from the user, and switchback registers that
|
|
15
|
+
* endpoint itself. Once configured it appears in the registry like any other
|
|
16
|
+
* provider, so later edits are prefilled from the config rather than guessed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One classifier model a provider ships, as pi's registry reports it. */
|
|
20
|
+
export interface ClassifierModelSummary {
|
|
21
|
+
/** Model id to write into the config. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Wire API that model speaks (the value for the config's `api` key). */
|
|
24
|
+
api: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One provider as pi's registry reports it, reduced to what the wizard needs. */
|
|
28
|
+
export interface ClassifierProviderSource {
|
|
29
|
+
/** Provider id. */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Provider display name. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** Provider base URL, when the provider declares one. */
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
/** Classifier models this provider ships (empty when it has none). */
|
|
36
|
+
classifiers: readonly ClassifierModelSummary[];
|
|
37
|
+
/** How many chat models pi already has for this provider (clash detection). */
|
|
38
|
+
chatModels: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One choice in the decision-model wizard's provider step. */
|
|
42
|
+
export interface ClassifierProviderOption {
|
|
43
|
+
/** Provider id written to the config. */
|
|
44
|
+
provider: string;
|
|
45
|
+
/** Choice label, e.g. "typesafe — TypeSafe". */
|
|
46
|
+
label: string;
|
|
47
|
+
/** Provider display name, without the id prefix the label carries. */
|
|
48
|
+
displayName: string;
|
|
49
|
+
/**
|
|
50
|
+
* Base URL. Present when the provider declares one; absent means "resolve
|
|
51
|
+
* from pi's catalog", i.e. the config carries no `baseUrl` key.
|
|
52
|
+
*/
|
|
53
|
+
baseUrl?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Wire API to write alongside `baseUrl`, taken from the provider's own
|
|
56
|
+
* classifier models. Absent when the provider declares none.
|
|
57
|
+
*/
|
|
58
|
+
api?: string;
|
|
59
|
+
/** Classifier model ids the provider ships, sorted. */
|
|
60
|
+
models: readonly string[];
|
|
61
|
+
/**
|
|
62
|
+
* True for an endpoint the user named themselves (the wizard's "Other"
|
|
63
|
+
* path, or a provider already configured with a direct endpoint). Everything
|
|
64
|
+
* pi's catalog reports is false: those are resolved, not registered.
|
|
65
|
+
*/
|
|
66
|
+
local: boolean;
|
|
67
|
+
/** How many chat models pi already has for this provider; 0 when unknown. */
|
|
68
|
+
chatModels: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the provider choices from pi's registry view: every provider that ships
|
|
73
|
+
* at least one classifier model, alphabetically. A provider with no classifier
|
|
74
|
+
* models is left out rather than offered as a dead end - a direct endpoint is
|
|
75
|
+
* reachable through the wizard's "Other" choice.
|
|
76
|
+
*/
|
|
77
|
+
export function buildClassifierProviders(source: readonly ClassifierProviderSource[]): ClassifierProviderOption[] {
|
|
78
|
+
const options: ClassifierProviderOption[] = [];
|
|
79
|
+
for (const provider of source) {
|
|
80
|
+
if (provider.classifiers.length === 0) continue;
|
|
81
|
+
const models = provider.classifiers.map((model) => model.id);
|
|
82
|
+
const api = provider.classifiers[0]?.api;
|
|
83
|
+
options.push({
|
|
84
|
+
provider: provider.id,
|
|
85
|
+
label: `${provider.id} — ${provider.name}`,
|
|
86
|
+
displayName: provider.name,
|
|
87
|
+
...(provider.baseUrl !== undefined ? { baseUrl: provider.baseUrl } : {}),
|
|
88
|
+
...(api !== undefined ? { api } : {}),
|
|
89
|
+
models: [...models].sort(),
|
|
90
|
+
local: false,
|
|
91
|
+
chatModels: provider.chatModels,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
options.sort((a, b) => a.provider.localeCompare(b.provider));
|
|
95
|
+
return options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A short note for a choice's screen title: where the prefilled base URL came
|
|
100
|
+
* from, so the value is explainable rather than magic.
|
|
101
|
+
*/
|
|
102
|
+
export function classifierBaseUrlNote(option: ClassifierProviderOption): string {
|
|
103
|
+
if (option.baseUrl === undefined) return "resolved from pi's model catalog (no baseUrl needed)";
|
|
104
|
+
return `from pi's model catalog: ${option.baseUrl}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether writing a direct endpoint (`baseUrl`) for this choice would make pi
|
|
109
|
+
* replace models it already owns.
|
|
110
|
+
*
|
|
111
|
+
* pi's `applyExtension` returns `config.models.map(...)` whenever an extension
|
|
112
|
+
* registers a provider with a model list, so declaring a classifier under a
|
|
113
|
+
* provider that already serves chat models drops those chat models for the
|
|
114
|
+
* session. Derived from the registry rather than a list of "reserved" ids:
|
|
115
|
+
* whatever pi has models for is reserved, by definition.
|
|
116
|
+
*/
|
|
117
|
+
export function directEndpointWouldClobber(option: ClassifierProviderOption): boolean {
|
|
118
|
+
return option.chatModels > 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build a suggested decision-model name from the provider + model id. Used as
|
|
123
|
+
* the prefilled default in the last wizard step. Keeps the user's keystrokes
|
|
124
|
+
* to a Tab + Enter for the common case of accepting the suggestion.
|
|
125
|
+
*/
|
|
126
|
+
export function defaultDecisionModelName(provider: string, modelId: string): string {
|
|
127
|
+
const cleanedModel = modelId.replace(/[:/]/g, "-").replace(/-latest$/u, "");
|
|
128
|
+
return `${provider}-${cleanedModel}`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live discovery of System One-compatible models from a local Ollama server.
|
|
3
|
+
*
|
|
4
|
+
* Ollama v0.35+ advertises model capabilities in `/api/tags`, and a model that
|
|
5
|
+
* can answer the Jev System One prompt is listed with `"decision"` in its
|
|
6
|
+
* capabilities array. The wizard surfaces those ids in the model step, so a
|
|
7
|
+
* freshly configured endpoint does not have to wait for a routing failure to
|
|
8
|
+
* discover the wrong model id.
|
|
9
|
+
*
|
|
10
|
+
* llama-server (llama.cpp) has no equivalent endpoint, so the wizard falls back
|
|
11
|
+
* to "type the model id" for that endpoint. Future work may add a /v1/models
|
|
12
|
+
* shim there.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { FetchLike } from "./systemone.ts";
|
|
16
|
+
|
|
17
|
+
/** One Ollama model as `/api/tags` reports it. */
|
|
18
|
+
export interface OllamaModelSummary {
|
|
19
|
+
/** The model id as Ollama writes it (e.g. `nimble:latest`). */
|
|
20
|
+
id: string;
|
|
21
|
+
/** True iff the model lists `"decision"` in its capabilities. */
|
|
22
|
+
decisionCapable: boolean;
|
|
23
|
+
/** All advertised capabilities, for diagnostics. */
|
|
24
|
+
capabilities: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The default probe timeout for one `/api/tags` request. */
|
|
28
|
+
export const OLLAMA_DISCOVERY_TIMEOUT_MS = 10_000;
|
|
29
|
+
|
|
30
|
+
/** Shape we read from `/api/tags`. */
|
|
31
|
+
interface OllamaTagsResponse {
|
|
32
|
+
models?: Array<{
|
|
33
|
+
name?: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
details?: { capabilities?: string[] };
|
|
36
|
+
capabilities?: string[];
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One `/api/tags` round-trip against a local Ollama server. The caller supplies
|
|
42
|
+
* the Ollama root (e.g. `http://localhost:11434`), not the System One base URL -
|
|
43
|
+
* Ollama's `/api/tags` lives at the root regardless of the System One endpoint's
|
|
44
|
+
* `/v1` prefix.
|
|
45
|
+
*
|
|
46
|
+
* Returns the empty array on any error (network failure, non-200, malformed
|
|
47
|
+
* body) and leaves the error to the caller via the `error` field on the result.
|
|
48
|
+
* The wizard prefers "show what we know" over a hard failure when discovery is
|
|
49
|
+
* best-effort.
|
|
50
|
+
*/
|
|
51
|
+
export async function discoverOllamaModels(opts: {
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
fetch?: FetchLike;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}): Promise<{ models: OllamaModelSummary[]; error?: string }> {
|
|
56
|
+
const timeoutMs = opts.timeoutMs ?? OLLAMA_DISCOVERY_TIMEOUT_MS;
|
|
57
|
+
const url = ollamaTagsUrl(opts.baseUrl);
|
|
58
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
59
|
+
if (typeof fetchImpl !== "function") {
|
|
60
|
+
return { models: [], error: "no fetch implementation available" };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetchImpl(url, {
|
|
64
|
+
method: "GET",
|
|
65
|
+
headers: { accept: "application/json" },
|
|
66
|
+
body: "",
|
|
67
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
return { models: [], error: `ollama /api/tags ${response.status}` };
|
|
71
|
+
}
|
|
72
|
+
const json = (await response.json()) as OllamaTagsResponse;
|
|
73
|
+
const models: OllamaModelSummary[] = [];
|
|
74
|
+
for (const entry of json.models ?? []) {
|
|
75
|
+
const id = entry.name ?? entry.model;
|
|
76
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
77
|
+
const capsRaw = entry.capabilities ?? entry.details?.capabilities ?? [];
|
|
78
|
+
const capabilities = capsRaw.filter((cap): cap is string => typeof cap === "string");
|
|
79
|
+
models.push({
|
|
80
|
+
id,
|
|
81
|
+
decisionCapable: capabilities.includes("decision"),
|
|
82
|
+
capabilities,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { models };
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
88
|
+
return { models: [], error: message };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The `/api/tags` URL for a given Ollama root. Accepts roots in either form
|
|
94
|
+
* (`http://localhost:11434` or `http://localhost:11434/v1`); both work because
|
|
95
|
+
* `/api/tags` is not under the System One `/v1` prefix.
|
|
96
|
+
*/
|
|
97
|
+
function ollamaTagsUrl(baseUrl: string): string {
|
|
98
|
+
const trimmed = baseUrl.replace(/\/+$/u, "").replace(/\/v1$/u, "");
|
|
99
|
+
return `${trimmed}/api/tags`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Filter the discover result to System One-capable models only, sorted by id.
|
|
104
|
+
* The wizard uses this to populate the model picker; a model without the
|
|
105
|
+
* capability is offered under "Other (type the model id)" so they can still be
|
|
106
|
+
* entered by hand for testing.
|
|
107
|
+
*/
|
|
108
|
+
export function decisionCapableModels(models: readonly OllamaModelSummary[]): string[] {
|
|
109
|
+
return models
|
|
110
|
+
.filter((m) => m.decisionCapable)
|
|
111
|
+
.map((m) => m.id)
|
|
112
|
+
.sort();
|
|
113
|
+
}
|