@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.
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/{index-DDkbPYLz.css → index-UiqTcZca.css} +1 -1
- package/dist/client/assets/index-tBfBUY_8.js +74 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/catalog-service.d.ts +9 -0
- package/dist/server/application/catalog-service.js +11 -0
- package/dist/server/application/conversation-service.d.ts +9 -0
- package/dist/server/application/conversation-service.js +13 -0
- package/dist/server/application/provider-service.d.ts +63 -0
- package/dist/server/application/provider-service.js +136 -0
- package/dist/server/command-handler.d.ts +16 -1
- package/dist/server/command-handler.js +47 -3
- package/dist/server/index.js +13 -1
- package/dist/server/pi-session-events.d.ts +34 -1
- package/dist/server/pi-session-events.js +78 -20
- package/dist/server/pi-session-pool.d.ts +19 -1
- package/dist/server/pi-session-pool.js +25 -3
- package/dist/server/pi-session.d.ts +39 -1
- package/dist/server/pi-session.js +37 -2
- package/dist/server/providers.d.ts +160 -0
- package/dist/server/providers.js +396 -0
- package/dist/server/runtime.d.ts +65 -7
- package/dist/server/runtime.js +76 -13
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/types.d.ts +72 -0
- package/package.json +6 -1
- package/dist/client/assets/index-mAu39Bra.js +0 -74
|
@@ -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
|
+
}
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -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
|
|
435
|
-
*
|
|
436
|
-
*
|
|
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
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -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
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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 ?
|
|
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 ??
|
|
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;
|