@evandu/super-pi 0.1.104 → 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/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 +34 -1
- package/dist/server/index.js +13 -1
- package/dist/server/pi-session-pool.d.ts +19 -1
- package/dist/server/pi-session-pool.js +13 -1
- package/dist/server/pi-session.d.ts +10 -0
- package/dist/server/pi-session.js +11 -0
- package/dist/server/providers.d.ts +160 -0
- package/dist/server/providers.js +396 -0
- package/dist/server/runtime.d.ts +45 -0
- package/dist/server/runtime.js +38 -1
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/types.d.ts +72 -0
- package/package.json +4 -1
- package/dist/client/assets/index-mAu39Bra.js +0 -74
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { PublicProvider } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* THE PROVIDERS A PERSON ADDED, and the two files that make Pi know about them.
|
|
4
|
+
*
|
|
5
|
+
* WHAT THIS REPLACES. Pi's own answer to "I want to use a model that is not in your list" is a
|
|
6
|
+
* hand-written `~/.pi/agent/models.json` (the provider and model definitions) plus a key in
|
|
7
|
+
* `~/.pi/agent/auth.json`. There is no command for it and no UI for it - the files are the interface.
|
|
8
|
+
* This module is that interface, in a form: the settings window collects the same fields and writes
|
|
9
|
+
* the same files.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE FILES AND NOT A RUNTIME REGISTRATION. Pi's SDK can register a provider in memory
|
|
12
|
+
* (`ModelRuntime.registerProvider`) and hold a key in memory (`setRuntimeApiKey`), and both are
|
|
13
|
+
* tempting - they touch nothing of the user's. They do not work here, for two reasons that are
|
|
14
|
+
* facts about this codebase rather than opinions:
|
|
15
|
+
*
|
|
16
|
+
* 1. `ModelRuntime` is created PER SESSION here (`pi-session.ts:498`), so a provider registered on
|
|
17
|
+
* one instance is invisible to the next conversation.
|
|
18
|
+
* 2. A spawned Pi (`pi --mode rpc`, the one-shot mode) has no provider-registration command at all
|
|
19
|
+
* - its RPC surface is `set_model` and `get_available_models` and nothing that defines a
|
|
20
|
+
* provider. A provider that exists only in this process's memory cannot reach it.
|
|
21
|
+
*
|
|
22
|
+
* The files, by contrast, are read by BOTH paths: the SDK's `ModelRuntime.create()` loads
|
|
23
|
+
* `models.json` and `auth.json` from Pi's agent dir, and a spawned Pi does the same. So writing them
|
|
24
|
+
* is the only way one setting works everywhere - including in the terminal, where `pi` sees the same
|
|
25
|
+
* provider the person just added here.
|
|
26
|
+
*
|
|
27
|
+
* THE TRUTH SOURCE IS OURS, THE FILES ARE DERIVED. `<dataDir>/providers.json` (0600, holds the key)
|
|
28
|
+
* says which providers Super Pi manages. Pi's two files are rewritten from it after every change, by
|
|
29
|
+
* MERGING: only the provider ids we manage are touched, everything else in those files is left alone.
|
|
30
|
+
* That is what makes "delete the provider I added" safe, and it is why a person who hand-wrote their
|
|
31
|
+
* own provider keeps it.
|
|
32
|
+
*
|
|
33
|
+
* THE KEY NEVER REACHES THE BROWSER. It lives in two files with restrictive permissions - ours and
|
|
34
|
+
* Pi's `auth.json` - and in neither the state snapshot nor any event. What the browser gets is
|
|
35
|
+
* `hasKey: true`.
|
|
36
|
+
*/
|
|
37
|
+
/** One model of a provider, as the settings window collects it. */
|
|
38
|
+
export type ProviderModelDefinition = {
|
|
39
|
+
id: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
reasoning?: boolean;
|
|
42
|
+
imageInput?: boolean;
|
|
43
|
+
contextWindow?: number;
|
|
44
|
+
maxTokens?: number;
|
|
45
|
+
/**
|
|
46
|
+
* The two compatibility switches an OpenAI-compatible server sometimes needs.
|
|
47
|
+
*
|
|
48
|
+
* `supportsDeveloperRole: false` makes Pi send the system prompt as a `system` message instead of
|
|
49
|
+
* a `developer` one, which is what relays and local servers that predate the `developer` role need.
|
|
50
|
+
* `supportsReasoningEffort: false` stops Pi from sending `reasoning_effort` to a server that does
|
|
51
|
+
* not know the field. Both are per-provider in Pi's schema and per-model here, because a server can
|
|
52
|
+
* be one and not the other.
|
|
53
|
+
*/
|
|
54
|
+
supportsDeveloperRole?: boolean;
|
|
55
|
+
supportsReasoningEffort?: boolean;
|
|
56
|
+
};
|
|
57
|
+
/** One provider, as the settings window collects it. */
|
|
58
|
+
export type ProviderDefinition = {
|
|
59
|
+
id: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
baseUrl: string;
|
|
62
|
+
api: string;
|
|
63
|
+
/** The API key. Stored, never broadcast. */
|
|
64
|
+
apiKey?: string;
|
|
65
|
+
models: ProviderModelDefinition[];
|
|
66
|
+
};
|
|
67
|
+
/** One model, as the browser sees it. No key, no nothing secret - there is nothing secret here. */
|
|
68
|
+
export type { PublicProvider, PublicProviderModel } from "./types.js";
|
|
69
|
+
/**
|
|
70
|
+
* The API adapters Pi ships, as the ids a provider may name.
|
|
71
|
+
*
|
|
72
|
+
* Taken from Pi's own `KnownApi` union (`pi-ai/dist/types.d.ts`) rather than invented: the id ends
|
|
73
|
+
* up in `models.json` and Pi looks the adapter up by exactly this string, and an unknown one fails
|
|
74
|
+
* at REQUEST time ("No API provider registered for api: ...") rather than at load time. Offering the
|
|
75
|
+
* list in the form is the difference between a person finding the right value and guessing.
|
|
76
|
+
*/
|
|
77
|
+
export declare const KNOWN_APIS: readonly ["openai-completions", "anthropic-messages", "openai-responses", "azure-openai-responses", "openai-codex-responses", "mistral-conversations", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "pi-messages"];
|
|
78
|
+
/**
|
|
79
|
+
* Pi's global configuration directory - where `models.json` and `auth.json` live.
|
|
80
|
+
*
|
|
81
|
+
* `PI_CODING_AGENT_DIR` wins, because that is Pi's own rule (`core/config.js:getAgentDir`) and a
|
|
82
|
+
* person who set it expects Pi to read its config from there. The fallback is Pi's own default,
|
|
83
|
+
* `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`
|
|
84
|
+
* - on Windows the two disagree often enough to matter.
|
|
85
|
+
*/
|
|
86
|
+
export declare function piAgentDir(): string;
|
|
87
|
+
/** Pi's provider and model definitions. */
|
|
88
|
+
export declare function piModelsPath(agentDir?: string): string;
|
|
89
|
+
/** Pi's credential store. */
|
|
90
|
+
export declare function piAuthPath(agentDir?: string): string;
|
|
91
|
+
/**
|
|
92
|
+
* Strips JSONC comments, the way Pi's own parser does.
|
|
93
|
+
*
|
|
94
|
+
* Pi reads `models.json` with comments allowed, so a person may well have written comments in it -
|
|
95
|
+
* and Super Pi has to be able to READ that file to merge into it. A naive strip would corrupt a
|
|
96
|
+
* string that contains `//` (a base URL with a path, an api key with a slash), so this walks the
|
|
97
|
+
* text and tracks whether it is inside a string.
|
|
98
|
+
*/
|
|
99
|
+
export declare function stripJsonComments(text: string): string;
|
|
100
|
+
/**
|
|
101
|
+
* The providers Super Pi manages, and the writes that make Pi know about them.
|
|
102
|
+
*
|
|
103
|
+
* The class owns three files: its own registry, Pi's `models.json` and Pi's `auth.json`. Only the
|
|
104
|
+
* first is a truth source; the other two are rewritten from it.
|
|
105
|
+
*/
|
|
106
|
+
export declare class ProviderRegistry {
|
|
107
|
+
private readonly dataDir;
|
|
108
|
+
private readonly piDir;
|
|
109
|
+
/**
|
|
110
|
+
* `piDir` is Pi's own configuration directory, and it is a PARAMETER rather than a lookup at
|
|
111
|
+
* write time for one reason: a test that forgets to set `PI_CODING_AGENT_DIR` would then write to
|
|
112
|
+
* the developer's real `~/.pi/agent`. With the directory in hand, a test cannot reach it by
|
|
113
|
+
* accident - it has to be given one.
|
|
114
|
+
*/
|
|
115
|
+
constructor(dataDir: string, piDir?: string);
|
|
116
|
+
/** Super Pi's own registry: the providers it manages, with their keys. */
|
|
117
|
+
private get registryPath();
|
|
118
|
+
/**
|
|
119
|
+
* Reads the registry.
|
|
120
|
+
*
|
|
121
|
+
* A missing file is an empty registry, not an error: nobody has added a provider yet. A file that
|
|
122
|
+
* cannot be parsed IS an error, because reading it as empty would let the next write replace
|
|
123
|
+
* somebody's keys with nothing.
|
|
124
|
+
*/
|
|
125
|
+
load(): Promise<ProviderDefinition[]>;
|
|
126
|
+
/** Writes the registry. The key lives here, so the file is 0600. */
|
|
127
|
+
save(providers: ProviderDefinition[]): Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* Makes Pi's files match the registry.
|
|
130
|
+
*
|
|
131
|
+
* `previousIds` are the providers Super Pi managed before this change: they are the only ones
|
|
132
|
+
* whose entries may be REMOVED. An id that is in Pi's `models.json` and not in `previousIds` belongs
|
|
133
|
+
* to the person who wrote that file, and touching it would be Super Pi editing a provider it does
|
|
134
|
+
* not own - so the write is refused with a message naming the id.
|
|
135
|
+
*/
|
|
136
|
+
applyToPi(previous: ProviderDefinition[], next: ProviderDefinition[]): Promise<void>;
|
|
137
|
+
/**
|
|
138
|
+
* Writes the keys into Pi's `auth.json`.
|
|
139
|
+
*
|
|
140
|
+
* A credential is REMOVED only when it is the one Super Pi wrote: the same provider id and the
|
|
141
|
+
* same key. A person who replaced our key with their own - or who ran Pi's `/login` and put an
|
|
142
|
+
* OAuth token there - keeps it. Deleting somebody else's credential because they removed our
|
|
143
|
+
* provider would be the worst version of this feature.
|
|
144
|
+
*
|
|
145
|
+
* TWO CASES, and the second is the one that is easy to miss: the provider can be gone ENTIRELY
|
|
146
|
+
* (it was removed), or it can still be there with its key GONE (the person asked for the key to be
|
|
147
|
+
* cleared). Both have to take the credential with them, or "clear the key" would leave a working
|
|
148
|
+
* key sitting in Pi's auth file.
|
|
149
|
+
*/
|
|
150
|
+
private applyKeysToPi;
|
|
151
|
+
}
|
|
152
|
+
/** The provider list as the browser sees it: the key becomes a fact. */
|
|
153
|
+
export declare function publicProviders(providers: ProviderDefinition[]): PublicProvider[];
|
|
154
|
+
/**
|
|
155
|
+
* Checks what the form collected and returns it in the shape the registry stores.
|
|
156
|
+
*
|
|
157
|
+
* EVERY RULE HERE SAYS WHY, because a message a person reads is the only validation they get: the
|
|
158
|
+
* server cannot ask a follow-up question, so a refusal has to name the field and the reason.
|
|
159
|
+
*/
|
|
160
|
+
export declare function validateProvider(input: unknown): ProviderDefinition;
|
|
@@ -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
|
*
|
|
@@ -580,6 +594,17 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
580
594
|
* otherwise be re-imported on every picker open.
|
|
581
595
|
*/
|
|
582
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;
|
|
583
608
|
/** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
|
|
584
609
|
private warnedMissingExtension;
|
|
585
610
|
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
@@ -1031,6 +1056,26 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
1031
1056
|
followUp: string[];
|
|
1032
1057
|
}>;
|
|
1033
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;
|
|
1034
1079
|
/**
|
|
1035
1080
|
* The model/thinking catalog, from Pi, cached.
|
|
1036
1081
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -342,6 +342,17 @@ export class PiRpcRuntime {
|
|
|
342
342
|
* otherwise be re-imported on every picker open.
|
|
343
343
|
*/
|
|
344
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;
|
|
345
356
|
/** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
|
|
346
357
|
warnedMissingExtension = false;
|
|
347
358
|
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
@@ -429,7 +440,7 @@ export class PiRpcRuntime {
|
|
|
429
440
|
// ABOUT that session and stop being true with it.
|
|
430
441
|
this.sessionHosts.delete(conversationId);
|
|
431
442
|
this.reportedSessionFiles.delete(conversationId);
|
|
432
|
-
}, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId));
|
|
443
|
+
}, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId), () => this.modelEpoch);
|
|
433
444
|
}
|
|
434
445
|
/**
|
|
435
446
|
* The configuration of the session that was built for a conversation, for tests to inspect.
|
|
@@ -2242,6 +2253,32 @@ export class PiRpcRuntime {
|
|
|
2242
2253
|
}
|
|
2243
2254
|
return answer;
|
|
2244
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
|
+
}
|
|
2245
2282
|
/**
|
|
2246
2283
|
* The model/thinking catalog, from Pi, cached.
|
|
2247
2284
|
*
|