@evandu/super-pi 0.1.103 → 0.1.104

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.
@@ -24,5 +24,5 @@
24
24
  "src/client/types.ts": "c2d8b16e056173a3951d87f3e7b63dcc53dc9178a5e73fa3e48b2d1ffd4e93c7",
25
25
  "src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
26
26
  },
27
- "recordedAt": "2026-10-21T13:33:05.700Z"
27
+ "recordedAt": "2026-10-21T15:04:31.114Z"
28
28
  }
@@ -1,6 +1,7 @@
1
1
  import { type Store } from "../store.js";
2
2
  import type { AgentRuntime } from "../runtime.js";
3
3
  import type { SubagentRunRecorder } from "../subagent-runs.js";
4
+ import type { ModelSelection } from "../types.js";
4
5
  import type { EventBroadcaster } from "../server-context.js";
5
6
  import type { CatalogService } from "./catalog-service.js";
6
7
  import type { RunService } from "./run-service.js";
@@ -45,6 +46,14 @@ export declare class ConversationService {
45
46
  * a log and in a retry.
46
47
  */
47
48
  autoRenameConversation(conversationId: string): Promise<string>;
49
+ /**
50
+ * The model a conversation has selected, or undefined when it follows Pi's default.
51
+ *
52
+ * THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
53
+ * itself, so the levels of the model in force are the levels that conversation gets. A catalog read
54
+ * without this is a catalog of whatever Pi would default to.
55
+ */
56
+ modelSelectionFor(conversationId: string | undefined): ModelSelection | undefined;
48
57
  /**
49
58
  * Selects a conversation and broadcasts the catalog for that conversation's model.
50
59
  */
@@ -107,6 +107,19 @@ export class ConversationService {
107
107
  throw new Error("当前没有可用的 Pi 进程,无法生成标题");
108
108
  return this.runtime.summarizeConversation(conversation, workspace.path);
109
109
  }
110
+ /**
111
+ * The model a conversation has selected, or undefined when it follows Pi's default.
112
+ *
113
+ * THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
114
+ * itself, so the levels of the model in force are the levels that conversation gets. A catalog read
115
+ * without this is a catalog of whatever Pi would default to.
116
+ */
117
+ modelSelectionFor(conversationId) {
118
+ if (!conversationId)
119
+ return undefined;
120
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
121
+ return conversation?.model;
122
+ }
110
123
  /**
111
124
  * Selects a conversation and broadcasts the catalog for that conversation's model.
112
125
  */
@@ -237,10 +237,21 @@ export class CommandHandler {
237
237
  return;
238
238
  }
239
239
  if (command.type === "catalog.fetch") {
240
+ // THE CATALOG OF THE CONVERSATION BEING SHOWN, not of whatever Pi would default to.
241
+ //
242
+ // The THINKING LEVELS belong to the model in force: Pi computes them from the model itself, and a
243
+ // model that cannot reason offers only "off". This command used to ask for the catalog with NO
244
+ // selection, so the levels were always the DEFAULT model's - which means the picker offered the
245
+ // levels of a model the person was not using, and hid the ones they were. The model list is the
246
+ // same either way, so only the levels were wrong; the levels are the part a person reads.
247
+ //
248
+ // `conversation.select` already asked for it this way; this is the same answer, for the other
249
+ // way a client asks for it.
250
+ const selection = this.conversationService.modelSelectionFor(command.conversationId);
240
251
  if (sendToSocket)
241
- sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(undefined, command.conversationId) });
252
+ sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(selection, command.conversationId) });
242
253
  else
243
- await this.catalogService.broadcastCatalog(undefined, command.conversationId);
254
+ await this.catalogService.broadcastCatalog(selection, command.conversationId);
244
255
  return;
245
256
  }
246
257
  // The unfiltered catalog, for the settings window's model panel. Sent to the ASKING socket only:
@@ -73,6 +73,37 @@ export interface TurnEventHandlers {
73
73
  followUp: string[];
74
74
  }): void;
75
75
  }
76
+ /**
77
+ * The failure Pi reported in the message that ended the turn, if it reported one.
78
+ *
79
+ * WHY IT IS HELD AND NOT THROWN ON THE SPOT. Pi retries on its own: an assistant message that ends
80
+ * with `stopReason: "error"` may be followed by another assistant message, and only the LAST one
81
+ * decides whether the turn failed. So the failure is remembered when it arrives and raised when Pi
82
+ * SETTLES - which is the moment Pi has stopped working on its own and the answer is final. A failure
83
+ * raised at the moment it arrives would abort a turn Pi was about to retry, and the retry would then
84
+ * look like the user's problem.
85
+ *
86
+ * THE SAME RULES AS THE ONE-SHOT PATH, which already got this right (`runtime.ts`: the RPC drain loop
87
+ * reads the terminal outcome off `agent_end` and throws at `agent_settled`). A provider failure is a
88
+ * failure; a response that hit the length limit is an INCOMPLETE answer and is one too. An `"aborted"`
89
+ * is NOT a failure here: the person stopped the turn, and a stopped turn is a turn that ended - the
90
+ * one-shot path can tell the difference because it knows whether the signal was aborted, and the
91
+ * session path does not need to: an abort is the user's own action and the run service already
92
+ * records it as stopped.
93
+ */
94
+ export interface TurnFailure {
95
+ /** The message Pi reported, in its own words. */
96
+ message: string;
97
+ /** The raw reason, kept so a caller can tell a provider failure from anything else. */
98
+ stopReason: string;
99
+ }
100
+ /**
101
+ * Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
102
+ *
103
+ * ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
104
+ * custom messages, and none of those is a turn failing.
105
+ */
106
+ export declare function describeMessageFailure(record: unknown): TurnFailure | undefined;
76
107
  /**
77
108
  * A queue of turn items, with the waiters that are blocked on it.
78
109
  *
@@ -108,4 +139,6 @@ export declare class TurnQueue {
108
139
  * then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
109
140
  * understands is worth saying out loud exactly once.
110
141
  */
111
- export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void): boolean;
142
+ export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void, failure?: {
143
+ current: TurnFailure | undefined;
144
+ }): boolean;
@@ -62,6 +62,31 @@ export function describeDeliveredUserMessage(record) {
62
62
  .filter((part) => part !== undefined);
63
63
  return parts.length ? parts.join("\n") : undefined;
64
64
  }
65
+ /**
66
+ * Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
67
+ *
68
+ * ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
69
+ * custom messages, and none of those is a turn failing.
70
+ */
71
+ export function describeMessageFailure(record) {
72
+ const event = record;
73
+ if (event?.type !== "message_end")
74
+ return undefined;
75
+ const message = event.message;
76
+ if (!message || typeof message !== "object" || message.role !== "assistant")
77
+ return undefined;
78
+ const text = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
79
+ if (message.stopReason === "error") {
80
+ return { message: text || "Pi 报告模型或服务提供方错误。", stopReason: "error" };
81
+ }
82
+ if (message.stopReason === "length") {
83
+ // A response that hit the length limit is an INCOMPLETE answer. Pi compacts and continues after a
84
+ // recoverable one, so a turn that SETTLES with this reason is one whose answer is missing its
85
+ // end - and saying so is better than a turn that looks finished.
86
+ return { message: text || "模型输出达到本轮长度上限,回答未完成。", stopReason: "length" };
87
+ }
88
+ return undefined;
89
+ }
65
90
  /**
66
91
  * A queue of turn items, with the waiters that are blocked on it.
67
92
  *
@@ -149,11 +174,53 @@ export class TurnQueue {
149
174
  * then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
150
175
  * understands is worth saying out loud exactly once.
151
176
  */
152
- export function handleSessionEvent(event, queue, handlers, report) {
177
+ export function handleSessionEvent(event, queue, handlers, report, failure) {
153
178
  const record = event;
154
179
  if (!record || typeof record.type !== "string")
155
180
  return false;
156
181
  switch (record.type) {
182
+ case "message_end": {
183
+ // THE AUTHORITATIVE END OF AN ASSISTANT MESSAGE. `message_end` is the completed message, and its
184
+ // `stopReason` is the only place Pi says whether the model actually answered. The bridge used to
185
+ // ignore this event, which meant a provider that failed was indistinguishable from one that
186
+ // finished: the turn settled, the status became "completed", and the reader saw a turn that
187
+ // looked fine and had no answer in it.
188
+ //
189
+ // A failure is HELD, not thrown, because Pi retries on its own - the message that failed may be
190
+ // followed by another one. The retry starts a new assistant message, and THAT clears the held
191
+ // failure, so only the last message of the turn decides.
192
+ const reported = describeMessageFailure(record);
193
+ if (reported && failure)
194
+ failure.current = reported;
195
+ return true;
196
+ }
197
+ case "message_start": {
198
+ // A NEW ASSISTANT MESSAGE MEANS A NEW ATTEMPT. Pi's retry starts one, and the failure of the
199
+ // attempt before it is no longer the failure of this turn.
200
+ if (record.message?.role === "assistant" && failure)
201
+ failure.current = undefined;
202
+ // A STEER THIS TURN DELIVERED. Pi announces the moment a queued message stops waiting and starts
203
+ // being answered by emitting `message_start` with the user message it picked up, immediately after
204
+ // the `queue_update` above that shows the entry gone. The host uses it to split the turn so the
205
+ // answer lands under the message it answers.
206
+ //
207
+ // A NON-user `message_start` is not a delivery: it gets the same once-per-type report every other
208
+ // unrouted event gets, which is the same policy the RPC drain loop applies.
209
+ const delivered = describeDeliveredUserMessage(record);
210
+ if (delivered === undefined) {
211
+ if (record.message?.role === "assistant")
212
+ return true;
213
+ report(`type ${JSON.stringify(record.type)}`);
214
+ return true;
215
+ }
216
+ try {
217
+ handlers.onSteerDelivered?.(delivered);
218
+ }
219
+ catch (error) {
220
+ console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
221
+ }
222
+ return true;
223
+ }
157
224
  case "message_update": {
158
225
  const assistant = record.assistantMessageEvent;
159
226
  if (!assistant)
@@ -240,28 +307,19 @@ export function handleSessionEvent(event, queue, handlers, report) {
240
307
  }
241
308
  return true;
242
309
  }
243
- case "message_start": {
244
- // A STEER THIS TURN DELIVERED. Pi announces the moment a queued message stops waiting and starts
245
- // being answered by emitting `message_start` with the user message it picked up, immediately after
246
- // the `queue_update` above that shows the entry gone. The host uses it to split the turn so the
247
- // answer lands under the message it answers.
310
+ case "agent_settled": {
311
+ // THE TURN IS OVER, AND IT MAY HAVE FAILED. Pi settling is the end of the turn, and a turn that
312
+ // ended because the provider failed has to say so: a failure that is not raised is a failed turn
313
+ // that the reader sees as a completed one, with no answer in it and no banner above it.
248
314
  //
249
- // A NON-user `message_start` is not a delivery: it gets the same once-per-type report every other
250
- // unrouted event gets, which is the same policy the RPC drain loop applies.
251
- const delivered = describeDeliveredUserMessage(record);
252
- if (delivered === undefined) {
253
- report(`type ${JSON.stringify(record.type)}`);
315
+ // The failure is raised HERE and not when it arrived, because Pi retries on its own - a message
316
+ // that failed may be followed by another one, and only the last one decides.
317
+ const held = failure?.current;
318
+ if (held) {
319
+ failure.current = undefined;
320
+ queue.push({ kind: "error", error: new Error(held.message) });
254
321
  return true;
255
322
  }
256
- try {
257
- handlers.onSteerDelivered?.(delivered);
258
- }
259
- catch (error) {
260
- console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
261
- }
262
- return true;
263
- }
264
- case "agent_settled": {
265
323
  queue.push({ kind: "end" });
266
324
  return true;
267
325
  }
@@ -188,9 +188,19 @@ export class PiSessionPool {
188
188
  const same = running?.provider === wanted.provider && (running?.id ?? running?.id) === wanted.modelId;
189
189
  if (!same) {
190
190
  try {
191
- await session.setModel({ provider: wanted.provider, id: wanted.modelId });
191
+ // THE WHOLE MODEL, NOT THE PAIR. Pi's `setModel` puts what it is given straight into the
192
+ // agent's state, so a `{provider, id}` pair would not be a shorthand for a model - it would BE
193
+ // the model the agent runs on, with no `api` and no `contextWindow`. Measured: with the whole
194
+ // model the session reports `api=bedrock-converse-stream`, `contextWindow=1000000` and the
195
+ // thinking levels `[off, minimal, low, medium, high]`; with the pair the api and the context
196
+ // window are null and the levels collapse to `[off]`, which is what the picker shows.
197
+ //
198
+ // The pair is still the fallback, because a session that cannot resolve a model should still
199
+ // be able to run the one it has.
200
+ const whole = session.resolveModel?.(wanted.provider, wanted.modelId);
201
+ await session.setModel(whole ?? { provider: wanted.provider, id: wanted.modelId });
192
202
  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}`);
203
+ console.warn(`[super-pi latency] conversation=${request.conversationId} phase=session.model-applied model=${wanted.provider}/${wanted.modelId} whole=${whole ? "yes" : "no"}`);
194
204
  }
195
205
  catch (error) {
196
206
  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.`);
@@ -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.
@@ -235,11 +239,35 @@ export interface PiSessionLike {
235
239
  * running session, so a model change does not need a new session - and making one would cost a
236
240
  * transcript replay, which is the most expensive thing this runtime does. A user who picks a different
237
241
  * model in the picker would otherwise pay for the whole conversation's history to be re-read.
242
+ *
243
+ * THE ARGUMENT MUST BE A WHOLE MODEL. Pi's `setModel` puts it straight into the agent's state
244
+ * (`agent-session.ts:2514`), so a `{provider, id}` pair is not a shorthand for a model - it IS the
245
+ * model the agent will use, and it has no `api`, no `contextWindow` and no `maxTokens`. Measured:
246
+ * with a whole model the session reports `api=bedrock-converse-stream`,
247
+ * `contextWindow=1000000` and the thinking levels `[off, minimal, low, medium, high]`; with a pair
248
+ * the api and the context window are null and the levels collapse to `[off]`. `resolveModel` is how
249
+ * the whole model is obtained.
238
250
  */
239
251
  setModel?(model: {
240
252
  provider: string;
241
253
  id: string;
254
+ [key: string]: unknown;
242
255
  }): Promise<void>;
256
+ /**
257
+ * The WHOLE model for a provider/id pair, the way Pi's own `set_model` gets it.
258
+ *
259
+ * Pi's RPC mode looks the pair up in the model runtime's snapshot and hands the FOUND MODEL to
260
+ * `session.setModel` (`rpc-mode.ts:470-477`); the pair alone is never what Pi takes. This is the same
261
+ * lookup, exposed so the caller does not have to own a second model runtime.
262
+ *
263
+ * Optional because a test double has no models; its absence means "this session cannot resolve a
264
+ * model", and the caller then falls back to the pair it has.
265
+ */
266
+ resolveModel?(provider: string, modelId: string): {
267
+ provider: string;
268
+ id: string;
269
+ [key: string]: unknown;
270
+ } | undefined;
243
271
  /** The thinking levels the session's current model supports. Pi's own answer, not a derivation. */
244
272
  getAvailableThinkingLevels?(): string[];
245
273
  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);
@@ -269,7 +277,23 @@ export async function createPiSession(creation) {
269
277
  };
270
278
  const { session } = await sdk.createAgentSession(options);
271
279
  traceSession(creation.sessionId, "sdk.session.created", startedAt);
272
- return session;
280
+ const live = session;
281
+ // THE WHOLE MODEL, LOOKED UP THE WAY PI LOOKS IT UP. Pi's `setModel` puts the model it is given
282
+ // straight into the agent's state, so what it needs is the WHOLE model - the one with an `api`, a
283
+ // `contextWindow` and a `maxTokens` - and not the `{provider, id}` pair the picker holds. The only
284
+ // thing that has a whole model is the model runtime this session was built with, and Pi's own RPC
285
+ // `set_model` does exactly this lookup before it calls `setModel` (`rpc-mode.ts:470-477`).
286
+ //
287
+ // It is attached to the session rather than kept beside it because the session is what outlives a
288
+ // turn: a model change applies to a LIVE session, and the live session is the thing that has to be
289
+ // able to answer "give me this model whole".
290
+ live.resolveModel = (provider, modelId) => {
291
+ const found = modelRuntime.getModel?.(provider, modelId);
292
+ if (!found || typeof found !== "object")
293
+ return undefined;
294
+ return found;
295
+ };
296
+ return live;
273
297
  });
274
298
  }
275
299
  function transcriptBytes(sessionManager) {
@@ -431,14 +431,27 @@ export declare function describeSteerDisposition(data: unknown): SteerDispositio
431
431
  /**
432
432
  * Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
433
433
  *
434
- * THE MODEL IS NOT PART OF THE KEY, and that is the second half of why the picker no longer starts a
435
- * process. The MODEL LIST is the same answer whichever model a conversation has selected - it is the
436
- * list of models Pi offers, not the ones one conversation may use. The only thing that varies with the
437
- * selection is the THINKING LEVELS, which belong to the model in use.
434
+ * THE MODEL LIST IS SHARED. The list of models Pi offers is the same answer whichever model a
435
+ * conversation has selected - it is not the list of models one conversation may use. So the list has
436
+ * ONE key, and every conversation shares it.
438
437
  *
439
- * So the key records just whether levels were asked for. Keying on the model (as this used to) meant a
440
- * cache miss for every model a person ever picked, and every miss was a process start - so the picker
441
- * went grey again after every model change, which is exactly when a person is moving between models.
438
+ * THE THINKING LEVELS BELONG TO THE MODEL IN USE, so they get a key PER MODEL. This used to be one
439
+ * "levels" key for every model, on the assumption that the level list is the same whichever model is
440
+ * selected - and that assumption is wrong: Pi computes the levels from the model itself
441
+ * (`getSupportedThinkingLevels`: a model that cannot reason offers only "off", and a model whose
442
+ * thinking map says so offers fewer). Measured with one key: after reading the levels for a reasoning
443
+ * model, a NON-reasoning model was offered the reasoning model's levels, and the other way round - so
444
+ * the picker offered levels the model in force does not have, and hid the ones it does. Super Pi
445
+ * cannot compute the levels itself instead: Pi's rule is not exported, and a second implementation of
446
+ * it would drift from Pi's.
447
+ *
448
+ * THE COST OF A KEY PER MODEL, and why it is not what it used to be. An earlier version of this
449
+ * comment said that keying on the model "meant a cache miss for every model a person ever picked, and
450
+ * every miss was a process start - so the picker went grey again after every model change". That was
451
+ * true when the catalog ALWAYS answered by spawning a Pi, which is no longer how the ordinary path
452
+ * works: the SDK path reads the levels out of a session (measured ~30ms, no process at all) and caches
453
+ * them per model. The one-shot path - a Pi the person named explicitly - does pay a spawn for a model
454
+ * it has not read yet, and that is the price of asking Pi rather than guessing at its rule.
442
455
  */
443
456
  export declare const catalogKey: (selection?: ModelSelection) => string;
444
457
  /**
@@ -196,16 +196,29 @@ const CATALOG_COMMAND_TIMEOUT_MS = 15_000;
196
196
  /**
197
197
  * Keys Pi's model answers by. Exported so the store and the tests agree with the runtime.
198
198
  *
199
- * THE MODEL IS NOT PART OF THE KEY, and that is the second half of why the picker no longer starts a
200
- * process. The MODEL LIST is the same answer whichever model a conversation has selected - it is the
201
- * list of models Pi offers, not the ones one conversation may use. The only thing that varies with the
202
- * selection is the THINKING LEVELS, which belong to the model in use.
199
+ * THE MODEL LIST IS SHARED. The list of models Pi offers is the same answer whichever model a
200
+ * conversation has selected - it is not the list of models one conversation may use. So the list has
201
+ * ONE key, and every conversation shares it.
203
202
  *
204
- * So the key records just whether levels were asked for. Keying on the model (as this used to) meant a
205
- * cache miss for every model a person ever picked, and every miss was a process start - so the picker
206
- * went grey again after every model change, which is exactly when a person is moving between models.
203
+ * THE THINKING LEVELS BELONG TO THE MODEL IN USE, so they get a key PER MODEL. This used to be one
204
+ * "levels" key for every model, on the assumption that the level list is the same whichever model is
205
+ * selected - and that assumption is wrong: Pi computes the levels from the model itself
206
+ * (`getSupportedThinkingLevels`: a model that cannot reason offers only "off", and a model whose
207
+ * thinking map says so offers fewer). Measured with one key: after reading the levels for a reasoning
208
+ * model, a NON-reasoning model was offered the reasoning model's levels, and the other way round - so
209
+ * the picker offered levels the model in force does not have, and hid the ones it does. Super Pi
210
+ * cannot compute the levels itself instead: Pi's rule is not exported, and a second implementation of
211
+ * it would drift from Pi's.
212
+ *
213
+ * THE COST OF A KEY PER MODEL, and why it is not what it used to be. An earlier version of this
214
+ * comment said that keying on the model "meant a cache miss for every model a person ever picked, and
215
+ * every miss was a process start - so the picker went grey again after every model change". That was
216
+ * true when the catalog ALWAYS answered by spawning a Pi, which is no longer how the ordinary path
217
+ * works: the SDK path reads the levels out of a session (measured ~30ms, no process at all) and caches
218
+ * them per model. The one-shot path - a Pi the person named explicitly - does pay a spawn for a model
219
+ * it has not read yet, and that is the price of asking Pi rather than guessing at its rule.
207
220
  */
208
- export const catalogKey = (selection) => (selection?.provider && selection.modelId ? "levels" : "list");
221
+ export const catalogKey = (selection) => (selection?.provider && selection.modelId ? `levels:${selection.provider}/${selection.modelId}` : "list");
209
222
  /**
210
223
  * Where the `task` extension lives, as an ABSOLUTE path.
211
224
  *
@@ -721,9 +734,14 @@ export class PiRpcRuntime {
721
734
  announced.add(key);
722
735
  console.warn(`PiRpcRuntime: unhandled Pi session event ${key} - consumed without action.`);
723
736
  };
737
+ // THE FAILURE THE TURN ENDED ON, if it ended on one. Held here rather than thrown on the spot,
738
+ // because Pi retries on its own and only the LAST assistant message of a turn decides whether the
739
+ // turn failed. The bridge clears it when a new assistant message starts, and raises it when Pi
740
+ // settles.
741
+ const failure = { current: undefined };
724
742
  const unsubscribe = session.subscribe((event) => {
725
743
  try {
726
- handleSessionEvent(event, queue, handlers, report);
744
+ handleSessionEvent(event, queue, handlers, report, failure);
727
745
  }
728
746
  catch (error) {
729
747
  console.warn(`PiRpcRuntime: the session event handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
@@ -2346,14 +2364,22 @@ export class PiRpcRuntime {
2346
2364
  const pick = (model) => (model ? { provider: model.provider, modelId: model.modelId, label: model.label } : undefined);
2347
2365
  // The levels belong to the SELECTED model, so they are asked of the model in question rather
2348
2366
  // than of whatever Pi would have defaulted to.
2367
+ //
2368
+ // AND WHEN THE SDK DOES NOT KNOW THAT MODEL, THE WHOLE READ IS HANDED OVER rather than answered
2369
+ // with another model's levels. The model in force may belong to a Pi the SDK is not (a Pi the
2370
+ // person named explicitly), and a list of levels for a DIFFERENT model is worse than no answer:
2371
+ // the picker would offer levels the model in force does not have.
2349
2372
  const forModel = selection ?? reader.defaultModel;
2350
- const levels = forModel ? await reader.thinkingLevelsFor(forModel) : [];
2373
+ const levels = forModel ? await reader.thinkingLevelsFor(forModel) : undefined;
2374
+ if (forModel && !levels)
2375
+ return undefined;
2376
+ const levelNames = levels ?? [];
2351
2377
  const value = {
2352
2378
  source: "pi",
2353
2379
  models,
2354
- levels,
2380
+ levels: levelNames,
2355
2381
  defaultModel: pick(reader.defaultModel ? describeModel({ provider: reader.defaultModel.provider, id: reader.defaultModel.modelId, name: reader.defaultModel.modelId }) : undefined),
2356
- defaultThinkingLevel: reader.defaultThinkingLevel ?? levels[0],
2382
+ defaultThinkingLevel: reader.defaultThinkingLevel ?? levelNames[0],
2357
2383
  fetchedAt: new Date().toISOString(),
2358
2384
  };
2359
2385
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.103",
3
+ "version": "0.1.104",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,8 @@
43
43
  "scripts/stream-checkpoint.test.mjs",
44
44
  "scripts/stream-transport.test.mjs",
45
45
  "scripts/pi-session.test.mjs",
46
+ "scripts/pi-model-switch.test.mjs",
47
+ "scripts/pi-thinking-levels.test.mjs",
46
48
  "scripts/diagnose-cli.test.mjs",
47
49
  "scripts/lan-access.test.mjs",
48
50
  "scripts/terminal-pty.test.mjs",