@evandu/super-pi 0.1.104 → 0.1.106
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 +8 -7
- package/dist/client/assets/index-DBR_t9lF.js +79 -0
- package/dist/client/assets/index-mo5Dvh_v.css +1 -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 +77 -0
- package/dist/server/application/provider-service.js +165 -0
- package/dist/server/application/run-service.d.ts +19 -0
- package/dist/server/application/run-service.js +78 -0
- package/dist/server/command-handler.d.ts +16 -1
- package/dist/server/command-handler.js +40 -1
- package/dist/server/content-search.d.ts +32 -0
- package/dist/server/content-search.js +155 -0
- package/dist/server/file-changes.d.ts +119 -0
- package/dist/server/file-changes.js +278 -0
- package/dist/server/file-diff.d.ts +38 -0
- package/dist/server/file-diff.js +97 -0
- package/dist/server/file-index.d.ts +56 -0
- package/dist/server/file-index.js +150 -0
- package/dist/server/files.d.ts +8 -0
- package/dist/server/files.js +3 -1
- package/dist/server/git.d.ts +17 -0
- package/dist/server/git.js +39 -0
- package/dist/server/http-server.js +121 -1
- package/dist/server/index.js +28 -1
- package/dist/server/pi-session-events.d.ts +9 -0
- package/dist/server/pi-session-events.js +12 -0
- 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 +67 -0
- package/dist/server/runtime.js +54 -2
- package/dist/server/server-context.d.ts +15 -0
- package/dist/server/types.d.ts +143 -0
- package/package.json +14 -1
- package/dist/client/assets/index-DDkbPYLz.css +0 -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
|
@@ -115,6 +115,28 @@ export interface AnswerInput {
|
|
|
115
115
|
* on a card but by its working directory on a call block would be worse than having neither.
|
|
116
116
|
*/
|
|
117
117
|
onToolCall?(call: ToolCallNotice): void;
|
|
118
|
+
/**
|
|
119
|
+
* Called when a tool that WRITES A FILE is announced, with the path it is about to write.
|
|
120
|
+
*
|
|
121
|
+
* WHY A SEPARATE CALLBACK RATHER THAN A FIELD ON `onToolCall`. Two reasons, and the second is the
|
|
122
|
+
* load-bearing one.
|
|
123
|
+
*
|
|
124
|
+
* The first: this is a fact about the FILESYSTEM, not about the transcript. The transcript's row for
|
|
125
|
+
* an `edit` is a card; this is the server learning that a file on disk is about to change, and the
|
|
126
|
+
* listener for it has to act BEFORE the change - which is the only moment the old content is still on
|
|
127
|
+
* the disk. A card that arrives after the fact is too late to be a baseline.
|
|
128
|
+
*
|
|
129
|
+
* The second: the listener's job is to capture the file's previous content, which means reading the
|
|
130
|
+
* disk, and that must not happen on the path that draws a card. `onToolCall` is called synchronously
|
|
131
|
+
* in the drain loop, between yields, and a disk read there would put an I/O stall in the middle of
|
|
132
|
+
* the stream - the latency the streaming lane exists to avoid. So this callback is fired and FORGOTTEN:
|
|
133
|
+
* the listener does its work on its own time and the drain loop moves on.
|
|
134
|
+
*
|
|
135
|
+
* The path is the tool's own spelling, resolved against the workspace by the listener - the runtime
|
|
136
|
+
* does not know which workspace a call belongs to, and a path it cannot check is a path it does not
|
|
137
|
+
* vouch for.
|
|
138
|
+
*/
|
|
139
|
+
onFileWrite?(path: string, toolName: string, toolCallId?: string): void;
|
|
118
140
|
/**
|
|
119
141
|
* Called on `tool_execution_end` for the same `id`, carrying how the call ended.
|
|
120
142
|
*
|
|
@@ -328,6 +350,20 @@ export interface AgentRuntime {
|
|
|
328
350
|
* selected model silently clamps away.
|
|
329
351
|
*/
|
|
330
352
|
catalog(selection?: ModelSelection): Promise<ModelCatalog>;
|
|
353
|
+
/**
|
|
354
|
+
* Throws away every cached answer about the models Pi offers.
|
|
355
|
+
*
|
|
356
|
+
* Optional, because a runtime with no Pi behind it has no cache to throw away - and because the
|
|
357
|
+
* only thing that changes the model list from outside is a person adding a provider, which is a
|
|
358
|
+
* thing a runtime with no Pi cannot have.
|
|
359
|
+
*/
|
|
360
|
+
invalidateCatalog?(): void;
|
|
361
|
+
/**
|
|
362
|
+
* Makes the live sessions stale, so the next turn builds one that knows the current models.
|
|
363
|
+
*
|
|
364
|
+
* Optional for the same reason: only a runtime with sessions behind it has any.
|
|
365
|
+
*/
|
|
366
|
+
bumpModelEpoch?(): void;
|
|
331
367
|
/**
|
|
332
368
|
* Reverts the conversation to the state right after `boundaryEntryId`, in Pi's own transcript.
|
|
333
369
|
*
|
|
@@ -580,6 +616,17 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
580
616
|
* otherwise be re-imported on every picker open.
|
|
581
617
|
*/
|
|
582
618
|
private sdkCatalogPromise?;
|
|
619
|
+
/**
|
|
620
|
+
* Bumped whenever the set of models Pi knows about changes.
|
|
621
|
+
*
|
|
622
|
+
* A live session holds its own `ModelRuntime`, built when the session was built, and that runtime
|
|
623
|
+
* does not know about a provider added afterwards. Pi's refusal to switch to a model its runtime
|
|
624
|
+
* does not have is swallowed by the session pool into a warning, so the turn would go on running
|
|
625
|
+
* the OLD model while the picker said the new one - a control that lies. The sessions are made
|
|
626
|
+
* STALE by this counter instead: each one is rebuilt at its next turn, which is a moment when
|
|
627
|
+
* throwing a session away costs nothing, and the new one is built with a runtime that knows.
|
|
628
|
+
*/
|
|
629
|
+
private modelEpoch;
|
|
583
630
|
/** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
|
|
584
631
|
private warnedMissingExtension;
|
|
585
632
|
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
@@ -1031,6 +1078,26 @@ export declare class PiRpcRuntime implements AgentRuntime {
|
|
|
1031
1078
|
followUp: string[];
|
|
1032
1079
|
}>;
|
|
1033
1080
|
steer(conversationId: string, message: string, images?: MessageImage[]): Promise<SteerDisposition>;
|
|
1081
|
+
/**
|
|
1082
|
+
* Throws away every cached answer about the models Pi offers.
|
|
1083
|
+
*
|
|
1084
|
+
* Called when the model list itself changes - which today means a person added a provider
|
|
1085
|
+
* through the settings window. The caches are three deep and all three have to go: the TTL map
|
|
1086
|
+
* (ten minutes), the in-flight map (a read that is already running would answer with the old
|
|
1087
|
+
* list), and the SDK catalog, which is remembered for the life of the process because it holds
|
|
1088
|
+
* objects that read the auth store and the model store from disk - and those files are exactly
|
|
1089
|
+
* what just changed.
|
|
1090
|
+
*/
|
|
1091
|
+
invalidateCatalog(): void;
|
|
1092
|
+
/**
|
|
1093
|
+
* Makes every live session stale, so the next turn builds one that knows the current models.
|
|
1094
|
+
*
|
|
1095
|
+
* WHY A COUNTER AND NOT A REBUILD. Rebuilding a session means throwing it away and reading its
|
|
1096
|
+
* transcript back, and a turn may be RUNNING right now. The counter is read by the session pool
|
|
1097
|
+
* as part of the fingerprint, so each session notices at its next acquire - between turns, where
|
|
1098
|
+
* a rebuild is free.
|
|
1099
|
+
*/
|
|
1100
|
+
bumpModelEpoch(): void;
|
|
1034
1101
|
/**
|
|
1035
1102
|
* The model/thinking catalog, from Pi, cached.
|
|
1036
1103
|
*
|