@evandu/super-pi 0.1.103 → 0.1.105

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.
@@ -0,0 +1,396 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * The API adapters Pi ships, as the ids a provider may name.
6
+ *
7
+ * Taken from Pi's own `KnownApi` union (`pi-ai/dist/types.d.ts`) rather than invented: the id ends
8
+ * up in `models.json` and Pi looks the adapter up by exactly this string, and an unknown one fails
9
+ * at REQUEST time ("No API provider registered for api: ...") rather than at load time. Offering the
10
+ * list in the form is the difference between a person finding the right value and guessing.
11
+ */
12
+ export const KNOWN_APIS = [
13
+ "openai-completions",
14
+ "anthropic-messages",
15
+ "openai-responses",
16
+ "azure-openai-responses",
17
+ "openai-codex-responses",
18
+ "mistral-conversations",
19
+ "google-generative-ai",
20
+ "google-vertex",
21
+ "bedrock-converse-stream",
22
+ "pi-messages",
23
+ ];
24
+ /** The context window a model gets when the form does not say. Pi's own default. */
25
+ const DEFAULT_CONTEXT_WINDOW = 128000;
26
+ /** The output budget a model gets when the form does not say. Pi's own default. */
27
+ const DEFAULT_MAX_TOKENS = 16384;
28
+ /**
29
+ * Pi's global configuration directory - where `models.json` and `auth.json` live.
30
+ *
31
+ * `PI_CODING_AGENT_DIR` wins, because that is Pi's own rule (`core/config.js:getAgentDir`) and a
32
+ * person who set it expects Pi to read its config from there. The fallback is Pi's own default,
33
+ * `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`
34
+ * - on Windows the two disagree often enough to matter.
35
+ */
36
+ export function piAgentDir() {
37
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
38
+ if (configured)
39
+ return configured;
40
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
41
+ return home ? join(home, ".pi", "agent") : join(".pi", "agent");
42
+ }
43
+ /** Pi's provider and model definitions. */
44
+ export function piModelsPath(agentDir = piAgentDir()) {
45
+ return join(agentDir, "models.json");
46
+ }
47
+ /** Pi's credential store. */
48
+ export function piAuthPath(agentDir = piAgentDir()) {
49
+ return join(agentDir, "auth.json");
50
+ }
51
+ /**
52
+ * Strips JSONC comments, the way Pi's own parser does.
53
+ *
54
+ * Pi reads `models.json` with comments allowed, so a person may well have written comments in it -
55
+ * and Super Pi has to be able to READ that file to merge into it. A naive strip would corrupt a
56
+ * string that contains `//` (a base URL with a path, an api key with a slash), so this walks the
57
+ * text and tracks whether it is inside a string.
58
+ */
59
+ export function stripJsonComments(text) {
60
+ let out = "";
61
+ let inString = false;
62
+ let inLineComment = false;
63
+ let inBlockComment = false;
64
+ for (let index = 0; index < text.length; index++) {
65
+ const char = text[index];
66
+ const next = text[index + 1];
67
+ if (inLineComment) {
68
+ if (char === "\n") {
69
+ inLineComment = false;
70
+ out += char;
71
+ }
72
+ else
73
+ out += " ";
74
+ continue;
75
+ }
76
+ if (inBlockComment) {
77
+ if (char === "*" && next === "/") {
78
+ inBlockComment = false;
79
+ out += " ";
80
+ index++;
81
+ }
82
+ else
83
+ out += char === "\n" ? "\n" : " ";
84
+ continue;
85
+ }
86
+ if (inString) {
87
+ out += char;
88
+ if (char === "\\" && next !== undefined) {
89
+ out += next;
90
+ index++;
91
+ }
92
+ else if (char === "\"")
93
+ inString = false;
94
+ continue;
95
+ }
96
+ if (char === "\"") {
97
+ inString = true;
98
+ out += char;
99
+ continue;
100
+ }
101
+ if (char === "/" && next === "/") {
102
+ inLineComment = true;
103
+ out += " ";
104
+ index++;
105
+ continue;
106
+ }
107
+ if (char === "/" && next === "*") {
108
+ inBlockComment = true;
109
+ out += " ";
110
+ index++;
111
+ continue;
112
+ }
113
+ out += char;
114
+ }
115
+ return out;
116
+ }
117
+ /** Reads a JSON file, returning undefined when it does not exist. */
118
+ async function readJsonFile(path) {
119
+ let text;
120
+ try {
121
+ text = await readFile(path, "utf8");
122
+ }
123
+ catch (error) {
124
+ if (error.code === "ENOENT")
125
+ return undefined;
126
+ throw error;
127
+ }
128
+ if (!text.trim())
129
+ return undefined;
130
+ try {
131
+ return JSON.parse(stripJsonComments(text));
132
+ }
133
+ catch (error) {
134
+ // A file Super Pi cannot parse is a file Super Pi must NOT overwrite: the person who wrote it
135
+ // put something in there this server does not understand, and replacing it with a parse of
136
+ // nothing would destroy their work.
137
+ throw new Error(`${path} 解析失败,为避免覆盖你的文件已停止写入:${error instanceof Error ? error.message : String(error)}`);
138
+ }
139
+ }
140
+ /** The permissions a file already has, or 0600 when it is new. */
141
+ async function fileMode(path) {
142
+ try {
143
+ const info = await stat(path);
144
+ return info.mode & 0o777;
145
+ }
146
+ catch {
147
+ return 0o600;
148
+ }
149
+ }
150
+ /**
151
+ * Writes a file atomically: a temporary file next to it, then a rename over the target.
152
+ *
153
+ * A rename is atomic on every platform Super Pi runs on, so a reader - Pi, or a person with an
154
+ * editor open - sees either the old file or the new one, never half of each. Writing in place would
155
+ * leave a window in which Pi reads a truncated `models.json` and loses every provider in it.
156
+ */
157
+ async function writeJsonAtomic(path, value) {
158
+ await mkdir(dirname(path), { recursive: true });
159
+ const mode = await fileMode(path);
160
+ const temporary = `${path}.${randomUUID()}.tmp`;
161
+ try {
162
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
163
+ await rename(temporary, path);
164
+ }
165
+ catch (error) {
166
+ await writeFile(temporary, "{}", { mode }).catch(() => { });
167
+ throw error;
168
+ }
169
+ }
170
+ /**
171
+ * The provider entry Super Pi writes into Pi's `models.json`.
172
+ *
173
+ * NO `apiKey` HERE, and that is a decision with a reason behind it: a value in `models.json` goes
174
+ * through Pi's config-value syntax, where `$NAME` means "read this environment variable" and
175
+ * `!command` means "run this and take its output". A key that happens to start with a dollar sign
176
+ * would be silently turned into something else. `auth.json` stores the key verbatim, and it is
177
+ * where Pi's own `/login` puts it, so that is where this goes.
178
+ */
179
+ function toPiProvider(provider) {
180
+ const models = provider.models.map(model => ({
181
+ id: model.id,
182
+ name: model.name ?? model.id,
183
+ reasoning: model.reasoning ?? false,
184
+ input: model.imageInput ? ["text", "image"] : ["text"],
185
+ contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
186
+ maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
187
+ ...(model.supportsDeveloperRole === undefined && model.supportsReasoningEffort === undefined
188
+ ? {}
189
+ : {
190
+ compat: {
191
+ ...(model.supportsDeveloperRole === undefined ? {} : { supportsDeveloperRole: model.supportsDeveloperRole }),
192
+ ...(model.supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort: model.supportsReasoningEffort }),
193
+ },
194
+ }),
195
+ }));
196
+ return {
197
+ ...(provider.name ? { name: provider.name } : {}),
198
+ baseUrl: provider.baseUrl,
199
+ api: provider.api,
200
+ models,
201
+ };
202
+ }
203
+ /**
204
+ * The providers Super Pi manages, and the writes that make Pi know about them.
205
+ *
206
+ * The class owns three files: its own registry, Pi's `models.json` and Pi's `auth.json`. Only the
207
+ * first is a truth source; the other two are rewritten from it.
208
+ */
209
+ export class ProviderRegistry {
210
+ dataDir;
211
+ piDir;
212
+ /**
213
+ * `piDir` is Pi's own configuration directory, and it is a PARAMETER rather than a lookup at
214
+ * write time for one reason: a test that forgets to set `PI_CODING_AGENT_DIR` would then write to
215
+ * the developer's real `~/.pi/agent`. With the directory in hand, a test cannot reach it by
216
+ * accident - it has to be given one.
217
+ */
218
+ constructor(dataDir, piDir = piAgentDir()) {
219
+ this.dataDir = dataDir;
220
+ this.piDir = piDir;
221
+ }
222
+ /** Super Pi's own registry: the providers it manages, with their keys. */
223
+ get registryPath() {
224
+ return join(this.dataDir, "providers.json");
225
+ }
226
+ /**
227
+ * Reads the registry.
228
+ *
229
+ * A missing file is an empty registry, not an error: nobody has added a provider yet. A file that
230
+ * cannot be parsed IS an error, because reading it as empty would let the next write replace
231
+ * somebody's keys with nothing.
232
+ */
233
+ async load() {
234
+ const raw = await readJsonFile(this.registryPath);
235
+ if (!raw || typeof raw !== "object")
236
+ return [];
237
+ const providers = raw.providers;
238
+ if (!Array.isArray(providers))
239
+ return [];
240
+ return providers.filter((provider) => Boolean(provider) && typeof provider === "object");
241
+ }
242
+ /** Writes the registry. The key lives here, so the file is 0600. */
243
+ async save(providers) {
244
+ await writeJsonAtomic(this.registryPath, { providers });
245
+ }
246
+ /**
247
+ * Makes Pi's files match the registry.
248
+ *
249
+ * `previousIds` are the providers Super Pi managed before this change: they are the only ones
250
+ * whose entries may be REMOVED. An id that is in Pi's `models.json` and not in `previousIds` belongs
251
+ * to the person who wrote that file, and touching it would be Super Pi editing a provider it does
252
+ * not own - so the write is refused with a message naming the id.
253
+ */
254
+ async applyToPi(previous, next) {
255
+ const previousIds = new Set(previous.map((provider) => provider.id));
256
+ const nextIds = new Set(next.map((provider) => provider.id));
257
+ const modelsPath = piModelsPath(this.piDir);
258
+ const existing = await readJsonFile(modelsPath) ?? {};
259
+ const existingProviders = existing.providers ?? {};
260
+ for (const provider of next) {
261
+ if (existingProviders[provider.id] !== undefined && !previousIds.has(provider.id)) {
262
+ throw new Error(`Pi 的 models.json 里已经有供应商「${provider.id}」,而它不是 Super Pi 添加的,所以没有动它。请换一个供应商 id,或者先在 Pi 的模型配置文件里删掉它。`);
263
+ }
264
+ }
265
+ const mergedProviders = { ...existingProviders };
266
+ for (const provider of next)
267
+ mergedProviders[provider.id] = toPiProvider(provider);
268
+ // The ones we managed and no longer do.
269
+ for (const id of previousIds)
270
+ if (!nextIds.has(id))
271
+ delete mergedProviders[id];
272
+ await writeJsonAtomic(modelsPath, { ...existing, providers: mergedProviders });
273
+ await this.applyKeysToPi(previous, next);
274
+ }
275
+ /**
276
+ * Writes the keys into Pi's `auth.json`.
277
+ *
278
+ * A credential is REMOVED only when it is the one Super Pi wrote: the same provider id and the
279
+ * same key. A person who replaced our key with their own - or who ran Pi's `/login` and put an
280
+ * OAuth token there - keeps it. Deleting somebody else's credential because they removed our
281
+ * provider would be the worst version of this feature.
282
+ *
283
+ * TWO CASES, and the second is the one that is easy to miss: the provider can be gone ENTIRELY
284
+ * (it was removed), or it can still be there with its key GONE (the person asked for the key to be
285
+ * cleared). Both have to take the credential with them, or "clear the key" would leave a working
286
+ * key sitting in Pi's auth file.
287
+ */
288
+ async applyKeysToPi(previous, next) {
289
+ const authPath = piAuthPath(this.piDir);
290
+ const auth = await readJsonFile(authPath) ?? {};
291
+ const nextById = new Map(next.map((provider) => [provider.id, provider]));
292
+ for (const provider of next) {
293
+ if (provider.apiKey)
294
+ auth[provider.id] = { type: "api_key", key: provider.apiKey };
295
+ }
296
+ for (const provider of previous) {
297
+ const updated = nextById.get(provider.id);
298
+ // The key is still wanted (the provider is unchanged, or it has a new one) - nothing to do.
299
+ if (updated && updated.apiKey)
300
+ continue;
301
+ if (!updated && !provider.apiKey)
302
+ continue;
303
+ const stored = auth[provider.id];
304
+ if (stored?.type === "api_key" && provider.apiKey && stored.key === provider.apiKey)
305
+ delete auth[provider.id];
306
+ }
307
+ await writeJsonAtomic(authPath, auth);
308
+ }
309
+ }
310
+ /** The provider list as the browser sees it: the key becomes a fact. */
311
+ export function publicProviders(providers) {
312
+ return providers.map(provider => ({
313
+ id: provider.id,
314
+ name: provider.name ?? provider.id,
315
+ baseUrl: provider.baseUrl,
316
+ api: provider.api,
317
+ hasKey: Boolean(provider.apiKey),
318
+ models: provider.models.map(model => ({
319
+ id: model.id,
320
+ name: model.name ?? model.id,
321
+ reasoning: model.reasoning ?? false,
322
+ imageInput: model.imageInput ?? false,
323
+ contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
324
+ maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
325
+ })),
326
+ }));
327
+ }
328
+ /**
329
+ * Checks what the form collected and returns it in the shape the registry stores.
330
+ *
331
+ * EVERY RULE HERE SAYS WHY, because a message a person reads is the only validation they get: the
332
+ * server cannot ask a follow-up question, so a refusal has to name the field and the reason.
333
+ */
334
+ export function validateProvider(input) {
335
+ const raw = (input ?? {});
336
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
337
+ if (!id)
338
+ throw new Error("供应商 id 不能为空");
339
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(id))
340
+ throw new Error("供应商 id 只能包含字母、数字、点、下划线和连字符,且必须以字母或数字开头");
341
+ const name = typeof raw.name === "string" ? raw.name.trim() : "";
342
+ const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
343
+ if (!baseUrl)
344
+ throw new Error("API 地址不能为空");
345
+ let parsed;
346
+ try {
347
+ parsed = new URL(baseUrl);
348
+ }
349
+ catch {
350
+ throw new Error(`API 地址不是合法的 URL:${baseUrl}`);
351
+ }
352
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
353
+ throw new Error("API 地址必须是 http:// 或 https://");
354
+ const api = typeof raw.api === "string" ? raw.api.trim() : "";
355
+ if (!api)
356
+ throw new Error("API 类型不能为空");
357
+ if (!KNOWN_APIS.includes(api))
358
+ throw new Error(`API 类型必须是下面之一:${KNOWN_APIS.join("、")}`);
359
+ const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : "";
360
+ const models = Array.isArray(raw.models) ? raw.models : [];
361
+ if (!models.length)
362
+ throw new Error("至少要添加一个模型");
363
+ const checked = models.map((entry) => {
364
+ const model = (entry ?? {});
365
+ const modelId = typeof model.id === "string" ? model.id.trim() : "";
366
+ if (!modelId)
367
+ throw new Error("模型 id 不能为空");
368
+ if (modelId.length > 200)
369
+ throw new Error("模型 id 太长了");
370
+ const modelName = typeof model.name === "string" ? model.name.trim() : "";
371
+ const contextWindow = typeof model.contextWindow === "number" ? Math.round(model.contextWindow) : undefined;
372
+ if (contextWindow !== undefined && (!Number.isFinite(contextWindow) || contextWindow <= 0))
373
+ throw new Error("上下文窗口必须是一个正数");
374
+ const maxTokens = typeof model.maxTokens === "number" ? Math.round(model.maxTokens) : undefined;
375
+ if (maxTokens !== undefined && (!Number.isFinite(maxTokens) || maxTokens <= 0))
376
+ throw new Error("最大输出 token 必须是一个正数");
377
+ return {
378
+ id: modelId,
379
+ ...(modelName ? { name: modelName } : {}),
380
+ ...(model.reasoning === true ? { reasoning: true } : {}),
381
+ ...(model.imageInput === true ? { imageInput: true } : {}),
382
+ ...(contextWindow === undefined ? {} : { contextWindow }),
383
+ ...(maxTokens === undefined ? {} : { maxTokens }),
384
+ ...(model.supportsDeveloperRole === false ? { supportsDeveloperRole: false } : {}),
385
+ ...(model.supportsReasoningEffort === false ? { supportsReasoningEffort: false } : {}),
386
+ };
387
+ });
388
+ return {
389
+ id,
390
+ ...(name ? { name } : {}),
391
+ baseUrl,
392
+ api,
393
+ ...(apiKey ? { apiKey } : {}),
394
+ models: checked,
395
+ };
396
+ }
@@ -328,6 +328,20 @@ export interface AgentRuntime {
328
328
  * selected model silently clamps away.
329
329
  */
330
330
  catalog(selection?: ModelSelection): Promise<ModelCatalog>;
331
+ /**
332
+ * Throws away every cached answer about the models Pi offers.
333
+ *
334
+ * Optional, because a runtime with no Pi behind it has no cache to throw away - and because the
335
+ * only thing that changes the model list from outside is a person adding a provider, which is a
336
+ * thing a runtime with no Pi cannot have.
337
+ */
338
+ invalidateCatalog?(): void;
339
+ /**
340
+ * Makes the live sessions stale, so the next turn builds one that knows the current models.
341
+ *
342
+ * Optional for the same reason: only a runtime with sessions behind it has any.
343
+ */
344
+ bumpModelEpoch?(): void;
331
345
  /**
332
346
  * Reverts the conversation to the state right after `boundaryEntryId`, in Pi's own transcript.
333
347
  *
@@ -431,14 +445,27 @@ export declare function describeSteerDisposition(data: unknown): SteerDispositio
431
445
  /**
432
446
  * Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
433
447
  *
434
- * THE MODEL IS NOT PART OF THE KEY, and that is the second half of why the picker no longer starts a
435
- * process. The MODEL LIST is the same answer whichever model a conversation has selected - it is the
436
- * list of models Pi offers, not the ones one conversation may use. The only thing that varies with the
437
- * selection is the THINKING LEVELS, which belong to the model in use.
448
+ * THE MODEL LIST IS SHARED. The list of models Pi offers is the same answer whichever model a
449
+ * conversation has selected - it is not the list of models one conversation may use. So the list has
450
+ * ONE key, and every conversation shares it.
438
451
  *
439
- * So the key records just whether levels were asked for. Keying on the model (as this used to) meant a
440
- * cache miss for every model a person ever picked, and every miss was a process start - so the picker
441
- * went grey again after every model change, which is exactly when a person is moving between models.
452
+ * THE THINKING LEVELS BELONG TO THE MODEL IN USE, so they get a key PER MODEL. This used to be one
453
+ * "levels" key for every model, on the assumption that the level list is the same whichever model is
454
+ * selected - and that assumption is wrong: Pi computes the levels from the model itself
455
+ * (`getSupportedThinkingLevels`: a model that cannot reason offers only "off", and a model whose
456
+ * thinking map says so offers fewer). Measured with one key: after reading the levels for a reasoning
457
+ * model, a NON-reasoning model was offered the reasoning model's levels, and the other way round - so
458
+ * the picker offered levels the model in force does not have, and hid the ones it does. Super Pi
459
+ * cannot compute the levels itself instead: Pi's rule is not exported, and a second implementation of
460
+ * it would drift from Pi's.
461
+ *
462
+ * THE COST OF A KEY PER MODEL, and why it is not what it used to be. An earlier version of this
463
+ * comment said that keying on the model "meant a cache miss for every model a person ever picked, and
464
+ * every miss was a process start - so the picker went grey again after every model change". That was
465
+ * true when the catalog ALWAYS answered by spawning a Pi, which is no longer how the ordinary path
466
+ * works: the SDK path reads the levels out of a session (measured ~30ms, no process at all) and caches
467
+ * them per model. The one-shot path - a Pi the person named explicitly - does pay a spawn for a model
468
+ * it has not read yet, and that is the price of asking Pi rather than guessing at its rule.
442
469
  */
443
470
  export declare const catalogKey: (selection?: ModelSelection) => string;
444
471
  /**
@@ -567,6 +594,17 @@ export declare class PiRpcRuntime implements AgentRuntime {
567
594
  * otherwise be re-imported on every picker open.
568
595
  */
569
596
  private sdkCatalogPromise?;
597
+ /**
598
+ * Bumped whenever the set of models Pi knows about changes.
599
+ *
600
+ * A live session holds its own `ModelRuntime`, built when the session was built, and that runtime
601
+ * does not know about a provider added afterwards. Pi's refusal to switch to a model its runtime
602
+ * does not have is swallowed by the session pool into a warning, so the turn would go on running
603
+ * the OLD model while the picker said the new one - a control that lies. The sessions are made
604
+ * STALE by this counter instead: each one is rebuilt at its next turn, which is a moment when
605
+ * throwing a session away costs nothing, and the new one is built with a runtime that knows.
606
+ */
607
+ private modelEpoch;
570
608
  /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
571
609
  private warnedMissingExtension;
572
610
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
@@ -1018,6 +1056,26 @@ export declare class PiRpcRuntime implements AgentRuntime {
1018
1056
  followUp: string[];
1019
1057
  }>;
1020
1058
  steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
1059
+ /**
1060
+ * Throws away every cached answer about the models Pi offers.
1061
+ *
1062
+ * Called when the model list itself changes - which today means a person added a provider
1063
+ * through the settings window. The caches are three deep and all three have to go: the TTL map
1064
+ * (ten minutes), the in-flight map (a read that is already running would answer with the old
1065
+ * list), and the SDK catalog, which is remembered for the life of the process because it holds
1066
+ * objects that read the auth store and the model store from disk - and those files are exactly
1067
+ * what just changed.
1068
+ */
1069
+ invalidateCatalog(): void;
1070
+ /**
1071
+ * Makes every live session stale, so the next turn builds one that knows the current models.
1072
+ *
1073
+ * WHY A COUNTER AND NOT A REBUILD. Rebuilding a session means throwing it away and reading its
1074
+ * transcript back, and a turn may be RUNNING right now. The counter is read by the session pool
1075
+ * as part of the fingerprint, so each session notices at its next acquire - between turns, where
1076
+ * a rebuild is free.
1077
+ */
1078
+ bumpModelEpoch(): void;
1021
1079
  /**
1022
1080
  * The model/thinking catalog, from Pi, cached.
1023
1081
  *
@@ -196,16 +196,29 @@ const CATALOG_COMMAND_TIMEOUT_MS = 15_000;
196
196
  /**
197
197
  * Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
198
198
  *
199
- * THE MODEL IS NOT PART OF THE KEY, and that is the second half of why the picker no longer starts a
200
- * process. The MODEL LIST is the same answer whichever model a conversation has selected - it is the
201
- * list of models Pi offers, not the ones one conversation may use. The only thing that varies with the
202
- * selection is the THINKING LEVELS, which belong to the model in use.
199
+ * THE MODEL LIST IS SHARED. The list of models Pi offers is the same answer whichever model a
200
+ * conversation has selected - it is not the list of models one conversation may use. So the list has
201
+ * ONE key, and every conversation shares it.
203
202
  *
204
- * So the key records just whether levels were asked for. Keying on the model (as this used to) meant a
205
- * cache miss for every model a person ever picked, and every miss was a process start - so the picker
206
- * went grey again after every model change, which is exactly when a person is moving between models.
203
+ * THE THINKING LEVELS BELONG TO THE MODEL IN USE, so they get a key PER MODEL. This used to be one
204
+ * "levels" key for every model, on the assumption that the level list is the same whichever model is
205
+ * selected - and that assumption is wrong: Pi computes the levels from the model itself
206
+ * (`getSupportedThinkingLevels`: a model that cannot reason offers only "off", and a model whose
207
+ * thinking map says so offers fewer). Measured with one key: after reading the levels for a reasoning
208
+ * model, a NON-reasoning model was offered the reasoning model's levels, and the other way round - so
209
+ * the picker offered levels the model in force does not have, and hid the ones it does. Super Pi
210
+ * cannot compute the levels itself instead: Pi's rule is not exported, and a second implementation of
211
+ * it would drift from Pi's.
212
+ *
213
+ * THE COST OF A KEY PER MODEL, and why it is not what it used to be. An earlier version of this
214
+ * comment said that keying on the model "meant a cache miss for every model a person ever picked, and
215
+ * every miss was a process start - so the picker went grey again after every model change". That was
216
+ * true when the catalog ALWAYS answered by spawning a Pi, which is no longer how the ordinary path
217
+ * works: the SDK path reads the levels out of a session (measured ~30ms, no process at all) and caches
218
+ * them per model. The one-shot path - a Pi the person named explicitly - does pay a spawn for a model
219
+ * it has not read yet, and that is the price of asking Pi rather than guessing at its rule.
207
220
  */
208
- export const catalogKey = (selection) => (selection?.provider && selection.modelId ? "levels" : "list");
221
+ export const catalogKey = (selection) => (selection?.provider && selection.modelId ? `levels:${selection.provider}/${selection.modelId}` : "list");
209
222
  /**
210
223
  * Where the `task` extension lives, as an ABSOLUTE path.
211
224
  *
@@ -329,6 +342,17 @@ export class PiRpcRuntime {
329
342
  * otherwise be re-imported on every picker open.
330
343
  */
331
344
  sdkCatalogPromise;
345
+ /**
346
+ * Bumped whenever the set of models Pi knows about changes.
347
+ *
348
+ * A live session holds its own `ModelRuntime`, built when the session was built, and that runtime
349
+ * does not know about a provider added afterwards. Pi's refusal to switch to a model its runtime
350
+ * does not have is swallowed by the session pool into a warning, so the turn would go on running
351
+ * the OLD model while the picker said the new one - a control that lies. The sessions are made
352
+ * STALE by this counter instead: each one is rebuilt at its next turn, which is a moment when
353
+ * throwing a session away costs nothing, and the new one is built with a runtime that knows.
354
+ */
355
+ modelEpoch = 0;
332
356
  /** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
333
357
  warnedMissingExtension = false;
334
358
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
@@ -416,7 +440,7 @@ export class PiRpcRuntime {
416
440
  // ABOUT that session and stop being true with it.
417
441
  this.sessionHosts.delete(conversationId);
418
442
  this.reportedSessionFiles.delete(conversationId);
419
- }, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId));
443
+ }, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId), () => this.modelEpoch);
420
444
  }
421
445
  /**
422
446
  * The configuration of the session that was built for a conversation, for tests to inspect.
@@ -721,9 +745,14 @@ export class PiRpcRuntime {
721
745
  announced.add(key);
722
746
  console.warn(`PiRpcRuntime: unhandled Pi session event ${key} - consumed without action.`);
723
747
  };
748
+ // THE FAILURE THE TURN ENDED ON, if it ended on one. Held here rather than thrown on the spot,
749
+ // because Pi retries on its own and only the LAST assistant message of a turn decides whether the
750
+ // turn failed. The bridge clears it when a new assistant message starts, and raises it when Pi
751
+ // settles.
752
+ const failure = { current: undefined };
724
753
  const unsubscribe = session.subscribe((event) => {
725
754
  try {
726
- handleSessionEvent(event, queue, handlers, report);
755
+ handleSessionEvent(event, queue, handlers, report, failure);
727
756
  }
728
757
  catch (error) {
729
758
  console.warn(`PiRpcRuntime: the session event handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -2224,6 +2253,32 @@ export class PiRpcRuntime {
2224
2253
  }
2225
2254
  return answer;
2226
2255
  }
2256
+ /**
2257
+ * Throws away every cached answer about the models Pi offers.
2258
+ *
2259
+ * Called when the model list itself changes - which today means a person added a provider
2260
+ * through the settings window. The caches are three deep and all three have to go: the TTL map
2261
+ * (ten minutes), the in-flight map (a read that is already running would answer with the old
2262
+ * list), and the SDK catalog, which is remembered for the life of the process because it holds
2263
+ * objects that read the auth store and the model store from disk - and those files are exactly
2264
+ * what just changed.
2265
+ */
2266
+ invalidateCatalog() {
2267
+ this.catalogs.clear();
2268
+ this.pendingCatalogs.clear();
2269
+ this.sdkCatalogPromise = undefined;
2270
+ }
2271
+ /**
2272
+ * Makes every live session stale, so the next turn builds one that knows the current models.
2273
+ *
2274
+ * WHY A COUNTER AND NOT A REBUILD. Rebuilding a session means throwing it away and reading its
2275
+ * transcript back, and a turn may be RUNNING right now. The counter is read by the session pool
2276
+ * as part of the fingerprint, so each session notices at its next acquire - between turns, where
2277
+ * a rebuild is free.
2278
+ */
2279
+ bumpModelEpoch() {
2280
+ this.modelEpoch += 1;
2281
+ }
2227
2282
  /**
2228
2283
  * The model/thinking catalog, from Pi, cached.
2229
2284
  *
@@ -2346,14 +2401,22 @@ export class PiRpcRuntime {
2346
2401
  const pick = (model) => (model ? { provider: model.provider, modelId: model.modelId, label: model.label } : undefined);
2347
2402
  // The levels belong to the SELECTED model, so they are asked of the model in question rather
2348
2403
  // than of whatever Pi would have defaulted to.
2404
+ //
2405
+ // AND WHEN THE SDK DOES NOT KNOW THAT MODEL, THE WHOLE READ IS HANDED OVER rather than answered
2406
+ // with another model's levels. The model in force may belong to a Pi the SDK is not (a Pi the
2407
+ // person named explicitly), and a list of levels for a DIFFERENT model is worse than no answer:
2408
+ // the picker would offer levels the model in force does not have.
2349
2409
  const forModel = selection ?? reader.defaultModel;
2350
- const levels = forModel ? await reader.thinkingLevelsFor(forModel) : [];
2410
+ const levels = forModel ? await reader.thinkingLevelsFor(forModel) : undefined;
2411
+ if (forModel && !levels)
2412
+ return undefined;
2413
+ const levelNames = levels ?? [];
2351
2414
  const value = {
2352
2415
  source: "pi",
2353
2416
  models,
2354
- levels,
2417
+ levels: levelNames,
2355
2418
  defaultModel: pick(reader.defaultModel ? describeModel({ provider: reader.defaultModel.provider, id: reader.defaultModel.modelId, name: reader.defaultModel.modelId }) : undefined),
2356
- defaultThinkingLevel: reader.defaultThinkingLevel ?? levels[0],
2419
+ defaultThinkingLevel: reader.defaultThinkingLevel ?? levelNames[0],
2357
2420
  fetchedAt: new Date().toISOString(),
2358
2421
  };
2359
2422
  return value;
@@ -7,6 +7,7 @@ import type { ConversationService } from "./application/conversation-service.js"
7
7
  import type { RunService } from "./application/run-service.js";
8
8
  import type { ExtensionUiService } from "./application/extension-ui-service.js";
9
9
  import type { CatalogService } from "./application/catalog-service.js";
10
+ import type { ProviderService } from "./application/provider-service.js";
10
11
  import type { ExecService } from "./exec-service.js";
11
12
  import type { ToolRegistry } from "./tool-registry.js";
12
13
  import type { ToolRunner } from "./tool-runner.js";
@@ -49,6 +50,8 @@ export interface ServerContext {
49
50
  runService: RunService;
50
51
  extensionUiService: ExtensionUiService;
51
52
  catalogService: CatalogService;
53
+ /** The providers a person added through the settings window. */
54
+ providerService: ProviderService;
52
55
  execService: ExecService;
53
56
  toolRegistry: ToolRegistry;
54
57
  toolRunner: ToolRunner;