@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.
@@ -78,6 +78,15 @@ export declare class PiSessionPool {
78
78
  * knows what one looks like.
79
79
  */
80
80
  private readonly mintCapability;
81
+ /**
82
+ * How many times the set of models Pi knows about has changed.
83
+ *
84
+ * Injected rather than imported for the same reason the capability minting is: the pool needs to
85
+ * know WHEN a session is stale, and the caller knows what changed. A provider added through the
86
+ * settings window makes every live session unable to reach the new model, and the next turn is
87
+ * where that gets fixed.
88
+ */
89
+ private readonly modelEpoch;
81
90
  private readonly sessions;
82
91
  constructor(
83
92
  /** Where Pi keeps its session files. */
@@ -104,7 +113,16 @@ export declare class PiSessionPool {
104
113
  * does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
105
114
  * knows what one looks like.
106
115
  */
107
- mintCapability?: (conversationId: string) => string);
116
+ mintCapability?: (conversationId: string) => string,
117
+ /**
118
+ * How many times the set of models Pi knows about has changed.
119
+ *
120
+ * Injected rather than imported for the same reason the capability minting is: the pool needs to
121
+ * know WHEN a session is stale, and the caller knows what changed. A provider added through the
122
+ * settings window makes every live session unable to reach the new model, and the next turn is
123
+ * where that gets fixed.
124
+ */
125
+ modelEpoch?: () => number);
108
126
  /**
109
127
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
110
128
  *
@@ -48,6 +48,7 @@ export class PiSessionPool {
48
48
  onSessionDestroyed;
49
49
  factory;
50
50
  mintCapability;
51
+ modelEpoch;
51
52
  sessions = new Map();
52
53
  constructor(
53
54
  /** Where Pi keeps its session files. */
@@ -74,13 +75,23 @@ export class PiSessionPool {
74
75
  * does not have to know what a capability is - it needs to know WHEN one is needed, and the caller
75
76
  * knows what one looks like.
76
77
  */
77
- mintCapability = () => "") {
78
+ mintCapability = () => "",
79
+ /**
80
+ * How many times the set of models Pi knows about has changed.
81
+ *
82
+ * Injected rather than imported for the same reason the capability minting is: the pool needs to
83
+ * know WHEN a session is stale, and the caller knows what changed. A provider added through the
84
+ * settings window makes every live session unable to reach the new model, and the next turn is
85
+ * where that gets fixed.
86
+ */
87
+ modelEpoch = () => 0) {
78
88
  this.sessionDir = sessionDir;
79
89
  this.repositoryRoot = repositoryRoot;
80
90
  this.agentDir = agentDir;
81
91
  this.onSessionDestroyed = onSessionDestroyed;
82
92
  this.factory = factory;
83
93
  this.mintCapability = mintCapability;
94
+ this.modelEpoch = modelEpoch;
84
95
  }
85
96
  /**
86
97
  * The session for a conversation, built if there is none and REPLACED if the live one is stale.
@@ -188,9 +199,19 @@ export class PiSessionPool {
188
199
  const same = running?.provider === wanted.provider && (running?.id ?? running?.id) === wanted.modelId;
189
200
  if (!same) {
190
201
  try {
191
- await session.setModel({ provider: wanted.provider, id: wanted.modelId });
202
+ // THE WHOLE MODEL, NOT THE PAIR. Pi's `setModel` puts what it is given straight into the
203
+ // agent's state, so a `{provider, id}` pair would not be a shorthand for a model - it would BE
204
+ // the model the agent runs on, with no `api` and no `contextWindow`. Measured: with the whole
205
+ // model the session reports `api=bedrock-converse-stream`, `contextWindow=1000000` and the
206
+ // thinking levels `[off, minimal, low, medium, high]`; with the pair the api and the context
207
+ // window are null and the levels collapse to `[off]`, which is what the picker shows.
208
+ //
209
+ // The pair is still the fallback, because a session that cannot resolve a model should still
210
+ // be able to run the one it has.
211
+ const whole = session.resolveModel?.(wanted.provider, wanted.modelId);
212
+ await session.setModel(whole ?? { provider: wanted.provider, id: wanted.modelId });
192
213
  if (process.env.SUPER_PI_TRACE_LATENCY === "1")
193
- console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.model-applied model=${wanted.provider}/${wanted.modelId}`);
214
+ console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.model-applied model=${wanted.provider}/${wanted.modelId} whole=${whole ? "yes" : "no"}`);
194
215
  }
195
216
  catch (error) {
196
217
  console.warn(`PiSessionPool: conversation ${request.conversationId} asked for ${wanted.provider}/${wanted.modelId} and Pi declined: ${error instanceof Error ? error.message : String(error)}. The turn will run on the model already loaded.`);
@@ -217,6 +238,7 @@ export class PiSessionPool {
217
238
  tools: request.tools,
218
239
  excludeTools: request.excludeTools,
219
240
  extensions: superPiExtensionPaths(this.repositoryRoot),
241
+ modelEpoch: this.modelEpoch(),
220
242
  };
221
243
  }
222
244
  /**
@@ -68,11 +68,15 @@ export interface SdkCatalog {
68
68
  *
69
69
  * Separate from the models because it is the expensive half: it needs a session, so a caller that
70
70
  * only wants the list does not pay for it.
71
+ *
72
+ * UNDEFINED MEANS "THIS RUNTIME DOES NOT KNOW THAT MODEL", and it is not the same answer as "that
73
+ * model has no levels but off". A caller that gets undefined asks whoever does know the model; a
74
+ * caller that got a list would be showing another model's levels.
71
75
  */
72
76
  thinkingLevelsFor(model: {
73
77
  provider: string;
74
78
  modelId: string;
75
- }): Promise<string[]>;
79
+ }): Promise<string[] | undefined>;
76
80
  }
77
81
  /**
78
82
  * Builds a zero-process catalog reader.
@@ -125,6 +129,16 @@ export interface SessionFingerprint {
125
129
  excludeTools: string[] | undefined;
126
130
  /** Which of Super Pi's own extensions were mounted. */
127
131
  extensions: string[];
132
+ /**
133
+ * How many times the set of models Pi knows about has changed.
134
+ *
135
+ * NOT THE MODEL, BUT THE WORLD THE MODEL LIVES IN. A session's `ModelRuntime` is built with it and
136
+ * does not learn about a provider added afterwards, so a session that keeps running on the old
137
+ * runtime cannot switch to a model that only exists in the new one - and Pi's refusal to switch is
138
+ * swallowed into a warning, which would leave the turn running a model the picker does not name.
139
+ * The counter makes such a session stale, and it is rebuilt at its next turn.
140
+ */
141
+ modelEpoch: number;
128
142
  }
129
143
  /**
130
144
  * Fingerprints are compared by VALUE, and this is where that comparison is spelled out.
@@ -235,11 +249,35 @@ export interface PiSessionLike {
235
249
  * running session, so a model change does not need a new session - and making one would cost a
236
250
  * transcript replay, which is the most expensive thing this runtime does. A user who picks a different
237
251
  * model in the picker would otherwise pay for the whole conversation's history to be re-read.
252
+ *
253
+ * THE ARGUMENT MUST BE A WHOLE MODEL. Pi's `setModel` puts it straight into the agent's state
254
+ * (`agent-session.ts:2514`), so a `{provider, id}` pair is not a shorthand for a model - it IS the
255
+ * model the agent will use, and it has no `api`, no `contextWindow` and no `maxTokens`. Measured:
256
+ * with a whole model the session reports `api=bedrock-converse-stream`,
257
+ * `contextWindow=1000000` and the thinking levels `[off, minimal, low, medium, high]`; with a pair
258
+ * the api and the context window are null and the levels collapse to `[off]`. `resolveModel` is how
259
+ * the whole model is obtained.
238
260
  */
239
261
  setModel?(model: {
240
262
  provider: string;
241
263
  id: string;
264
+ [key: string]: unknown;
242
265
  }): Promise<void>;
266
+ /**
267
+ * The WHOLE model for a provider/id pair, the way Pi's own `set_model` gets it.
268
+ *
269
+ * Pi's RPC mode looks the pair up in the model runtime's snapshot and hands the FOUND MODEL to
270
+ * `session.setModel` (`rpc-mode.ts:470-477`); the pair alone is never what Pi takes. This is the same
271
+ * lookup, exposed so the caller does not have to own a second model runtime.
272
+ *
273
+ * Optional because a test double has no models; its absence means "this session cannot resolve a
274
+ * model", and the caller then falls back to the pair it has.
275
+ */
276
+ resolveModel?(provider: string, modelId: string): {
277
+ provider: string;
278
+ id: string;
279
+ [key: string]: unknown;
280
+ } | undefined;
243
281
  /** The thinking levels the session's current model supports. Pi's own answer, not a derivation. */
244
282
  getAvailableThinkingLevels?(): string[];
245
283
  setThinkingLevel?(level: string): void;
@@ -103,6 +103,14 @@ export async function createSdkCatalog(cwd) {
103
103
  const cached = levelsByModel.get(key);
104
104
  if (cached)
105
105
  return cached;
106
+ // THE MODEL HAS TO BE FOUND FIRST, and the reason it has to be found is that a session created
107
+ // WITHOUT one runs on Pi's DEFAULT model - and then it answers with the DEFAULT MODEL'S LEVELS.
108
+ // That is a silent wrong answer: the picker would offer the levels of a model the person is not
109
+ // using, and hide the ones they are. A model this runtime does not know is not a model with no
110
+ // levels; it is a model whose levels have to be asked of whoever does know it.
111
+ const whole = modelRuntime.getModel?.(model.provider, model.modelId);
112
+ if (!whole)
113
+ return undefined;
106
114
  // A model that is not running has no levels of its own, so a short-lived in-memory session is
107
115
  // built to ask Pi. It costs a session - measured at ~30ms warm, and no process at all - and it is
108
116
  // paid once per model rather than once per picker open.
@@ -112,7 +120,7 @@ export async function createSdkCatalog(cwd) {
112
120
  modelRuntime,
113
121
  settingsManager,
114
122
  sessionManager: sdk.SessionManager.inMemory(cwd, { id: "catalog-levels" }),
115
- model: modelRuntime.getModel?.(model.provider, model.modelId),
123
+ model: whole,
116
124
  });
117
125
  try {
118
126
  const levels = sessionLevels(session);
@@ -177,6 +185,8 @@ export function sameFingerprint(a, b) {
177
185
  return false;
178
186
  if (!sameSet(a.extensions, b.extensions))
179
187
  return false;
188
+ if (a.modelEpoch !== b.modelEpoch)
189
+ return false;
180
190
  return true;
181
191
  }
182
192
  function sameSet(a, b) {
@@ -269,7 +279,23 @@ export async function createPiSession(creation) {
269
279
  };
270
280
  const { session } = await sdk.createAgentSession(options);
271
281
  traceSession(creation.sessionId, "sdk.session.created", startedAt);
272
- return session;
282
+ const live = session;
283
+ // THE WHOLE MODEL, LOOKED UP THE WAY PI LOOKS IT UP. Pi's `setModel` puts the model it is given
284
+ // straight into the agent's state, so what it needs is the WHOLE model - the one with an `api`, a
285
+ // `contextWindow` and a `maxTokens` - and not the `{provider, id}` pair the picker holds. The only
286
+ // thing that has a whole model is the model runtime this session was built with, and Pi's own RPC
287
+ // `set_model` does exactly this lookup before it calls `setModel` (`rpc-mode.ts:470-477`).
288
+ //
289
+ // It is attached to the session rather than kept beside it because the session is what outlives a
290
+ // turn: a model change applies to a LIVE session, and the live session is the thing that has to be
291
+ // able to answer "give me this model whole".
292
+ live.resolveModel = (provider, modelId) => {
293
+ const found = modelRuntime.getModel?.(provider, modelId);
294
+ if (!found || typeof found !== "object")
295
+ return undefined;
296
+ return found;
297
+ };
298
+ return live;
273
299
  });
274
300
  }
275
301
  function transcriptBytes(sessionManager) {
@@ -319,8 +345,17 @@ async function resolveSessionManager(sdk, creation) {
319
345
  * file goes out of its way not to do for a path it can derive. The derivation is Pi's own default:
320
346
  * `~/.pi/agent`, with the home directory taken from the environment rather than from `os.homedir()`,
321
347
  * because on Windows the two disagree often enough to matter.
348
+ *
349
+ * `PI_CODING_AGENT_DIR` WINS, exactly as it wins in Pi (`core/config.ts:getAgentDir`). A person who
350
+ * set it expects Pi to read its whole configuration from there, and this function used to disagree
351
+ * with Pi about it: Pi's model runtime read `models.json` from that directory while the session's
352
+ * resource loader read the skills from `~/.pi/agent`. Two halves of one Pi pointed at two
353
+ * directories, and only one of them was the one the person had configured.
322
354
  */
323
355
  function defaultAgentDir() {
356
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
357
+ if (configured)
358
+ return configured;
324
359
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
325
360
  return home ? join(home, ".pi", "agent") : join(".pi", "agent");
326
361
  }
@@ -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;