pi-llama-cpp 0.12.0 → 0.14.0

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.
Files changed (68) hide show
  1. package/README.md +20 -9
  2. package/package.json +3 -3
  3. package/src/api/client.ts +25 -0
  4. package/src/constants.ts +8 -5
  5. package/src/enums/status.ts +0 -1
  6. package/src/interfaces/endpoints/models.ts +1 -1
  7. package/src/interfaces/settings.ts +3 -2
  8. package/src/interfaces/sortBy.ts +4 -0
  9. package/src/managers/command/models.ts +247 -0
  10. package/src/managers/command.ts +29 -459
  11. package/src/managers/events.ts +2 -2
  12. package/src/managers/server.ts +29 -5
  13. package/src/managers/settings.ts +12 -88
  14. package/src/models/baseModel.ts +27 -10
  15. package/src/models/legacyModel.ts +2 -2
  16. package/src/models/routerModel.ts +2 -2
  17. package/src/models/singleModel.ts +1 -1
  18. package/src/server.ts +35 -48
  19. package/src/sse/client.ts +113 -59
  20. package/src/sse/fetch.ts +43 -0
  21. package/src/sse/manager.ts +9 -27
  22. package/src/sse/types.ts +0 -4
  23. package/src/ui/dialog/base.ts +118 -0
  24. package/src/ui/dialog/confirm.ts +45 -0
  25. package/src/ui/dialog/factory.ts +111 -0
  26. package/src/ui/dialog/input.ts +63 -0
  27. package/src/ui/dialog/options.ts +23 -0
  28. package/src/ui/editors/editorOptions.ts +43 -0
  29. package/src/ui/editors/itemBuilder.ts +47 -0
  30. package/src/ui/editors/listEditor.ts +291 -0
  31. package/src/ui/editors/override/entry.ts +24 -0
  32. package/src/ui/editors/override/entryEditor.ts +166 -0
  33. package/src/ui/editors/override/fields.ts +294 -0
  34. package/src/ui/editors/override/handlers.ts +118 -0
  35. package/src/ui/editors/override/itemBuilder.ts +42 -0
  36. package/src/ui/editors/override/overrideList.ts +127 -0
  37. package/src/ui/editors/server/builder.ts +60 -0
  38. package/src/ui/editors/server/fields.ts +84 -0
  39. package/src/ui/editors/server/handlers.ts +32 -0
  40. package/src/ui/editors/server/itemBuilder.ts +68 -0
  41. package/src/ui/editors/server/serverEditor.ts +161 -0
  42. package/src/ui/editors/server/utils.ts +45 -0
  43. package/src/ui/editors/server/wizard.ts +110 -0
  44. package/src/ui/editors/settingField.ts +37 -0
  45. package/src/ui/editors/settingsListFactory.ts +33 -0
  46. package/src/ui/settings/index.ts +237 -0
  47. package/src/ui/strings.ts +8 -4
  48. package/src/utils/health.ts +48 -0
  49. package/src/utils/serverIds.ts +21 -0
  50. package/src/utils/settingsStore.ts +1 -1
  51. package/src/utils/urlResolver.ts +129 -0
  52. package/src/utils/urls.ts +33 -13
  53. package/tests/commandManager.test.ts +38 -7
  54. package/tests/dialog.test.ts +95 -2
  55. package/tests/health.test.ts +116 -0
  56. package/tests/legacyModel.test.ts +34 -28
  57. package/tests/overrides.test.ts +123 -68
  58. package/tests/routerModel.test.ts +67 -68
  59. package/tests/server.test.ts +47 -16
  60. package/tests/serverManager.test.ts +4 -4
  61. package/tests/settings.test.ts +10 -8
  62. package/tests/singleModel.test.ts +12 -12
  63. package/tests/sseManager.test.ts +6 -24
  64. package/src/ui/dialog.ts +0 -287
  65. package/src/ui/overrideEntryEditor.ts +0 -119
  66. package/src/ui/overrideSettingsList.ts +0 -682
  67. package/src/ui/serverListEditor.ts +0 -32
  68. package/src/ui/serverSettingsList.ts +0 -466
@@ -1,54 +1,16 @@
1
- import {
2
- getSettingsListTheme,
3
- type ExtensionAPI,
4
- type ExtensionCommandContext,
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
5
4
  } from "@earendil-works/pi-coding-agent";
6
- import {
7
- AutocompleteItem,
8
- SettingsList,
9
- type SettingItem,
10
- } from "@earendil-works/pi-tui";
5
+ import { AutocompleteItem } from "@earendil-works/pi-tui";
11
6
  import { PROVIDER_NAME } from "../constants";
12
- import { Action } from "../enums/action";
13
- import { Mode } from "../enums/mode";
14
- import { Status } from "../enums/status";
15
- import { LlamaSettings } from "../interfaces/settings";
16
- import { BaseModel } from "../models/baseModel";
17
- import { createOverrideSettingsList } from "../ui/overrideSettingsList";
18
- import { ServerSettingsList } from "../ui/serverSettingsList";
19
- import { errorMessage } from "../utils/errors";
20
- import { EventManager } from "./events";
7
+ import { OverrideSettingsList } from "../ui/editors/override/overrideList";
8
+ import { ServerSettingsList } from "../ui/editors/server/serverEditor";
9
+ import { SettingsEditor } from "../ui/settings";
10
+ import { ModelsMenu } from "./command/models";
21
11
  import { ServerManager } from "./server";
22
12
  import type { LlamaSettingsManager } from "./settings";
23
13
 
24
- /**
25
- * Identifiers of the editable fields shown in `/models settings`.
26
- * Values match the scalar `LlamaSettings` keys.
27
- */
28
- export enum Options {
29
- REACT_TO_MODEL_SELECT = "reactToModelSelect",
30
- AUTOLOAD_ON_MESSAGE = "autoloadOnMessage",
31
- SORT_BY = "sortBy",
32
- POLLING_TIMEOUT = "pollingTimeout",
33
- SERVER_TIMEOUT = "serverTimeout",
34
- }
35
-
36
- type SortByValue = NonNullable<LlamaSettings["sortBy"]>;
37
-
38
- const SORT_VALUES: SortByValue[] = [
39
- "asc",
40
- "desc",
41
- "asc-name",
42
- "desc-name",
43
- "api",
44
- ];
45
-
46
- /** Presets (ms) for `pollingTimeout` */
47
- const POLLING_PRESETS = [15000, 30000, 60000, 120000, 300000];
48
-
49
- /** Presets (ms) for `serverTimeout` */
50
- const SERVER_PRESETS = [500, 1000, 2000, 5000, 10000];
51
-
52
14
  /**
53
15
  * `/models` subcommand completions. Module-level so
54
16
  * {@link CommandManager.getArgumentCompletions} doesn't rebuild the
@@ -82,105 +44,15 @@ const ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
82
44
  },
83
45
  ];
84
46
 
85
- /**
86
- * Formats milliseconds compactly for display (e.g. `500 -> "500ms"`,
87
- * `60000 -> "60s"`).
88
- */
89
- export const formatMs = (ms: number): string =>
90
- ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`;
91
-
92
- /**
93
- * Parses a value produced by `formatMs()` back to milliseconds.
94
- * Only ever called with values from the preset lists.
95
- */
96
- const parseMs = (value: string): number =>
97
- value.endsWith("ms")
98
- ? Number(value.slice(0, -2))
99
- : Number(value.slice(0, -1)) * 1000;
100
-
101
- /**
102
- * Builds the `SettingsList` items for `/models settings` from the current
103
- * (merged) values of the scalar `llamaSettings` fields.
104
- */
105
- export const buildSettingsItems = async (
106
- settings: LlamaSettingsManager,
107
- ): Promise<SettingItem[]> => {
108
- const { pollingTimeout, serverTimeout } = await settings.resolveTimeouts();
109
-
110
- return [
111
- {
112
- id: Options.REACT_TO_MODEL_SELECT,
113
- label: "React to model selection",
114
- description: "Load the model when you pick it in Pi (immediate)",
115
- currentValue: (await settings.resolveReactToModelSelect()) ? "on" : "off",
116
- values: ["on", "off"],
117
- },
118
- {
119
- id: Options.AUTOLOAD_ON_MESSAGE,
120
- label: "Autoload on message",
121
- description:
122
- "Auto-load the selected model when you send a message (immediate)",
123
- currentValue: (await settings.resolveAutoloadOnMessage()) ? "on" : "off",
124
- values: ["on", "off"],
125
- },
126
- {
127
- id: Options.SORT_BY,
128
- label: "Sort models by",
129
- description: "Order of models in /models (next open)",
130
- currentValue: await settings.resolveSortBy(),
131
- values: [...SORT_VALUES],
132
- },
133
- {
134
- id: Options.POLLING_TIMEOUT,
135
- label: "Polling timeout",
136
- description: "Max model-load wait (next model load)",
137
- currentValue: formatMs(pollingTimeout),
138
- values: POLLING_PRESETS.map(formatMs),
139
- },
140
- {
141
- id: Options.SERVER_TIMEOUT,
142
- label: "Server timeout",
143
- description: "Health check / SSE probe timeout (next model load)",
144
- currentValue: formatMs(serverTimeout),
145
- values: SERVER_PRESETS.map(formatMs),
146
- },
147
- ];
148
- };
149
-
150
- /**
151
- * Persists a change made in the settings menu.
152
- * Maps the `SettingsList` id/value pair to the matching `llamaSettings`
153
- * key and writes it via `LlamaSettingsManager.setLlamaSetting()`.
154
- */
155
- export const applySettingChange = async (
156
- id: string,
157
- newValue: string,
158
- settings: LlamaSettingsManager,
159
- ): Promise<void> => {
160
- switch (id) {
161
- case Options.REACT_TO_MODEL_SELECT:
162
- await settings.setLlamaSetting("reactToModelSelect", newValue === "on");
163
- return;
164
- case Options.AUTOLOAD_ON_MESSAGE:
165
- await settings.setLlamaSetting("autoloadOnMessage", newValue === "on");
166
- return;
167
- case Options.SORT_BY:
168
- await settings.setLlamaSetting("sortBy", newValue as SortByValue);
169
- return;
170
- case Options.POLLING_TIMEOUT:
171
- await settings.setLlamaSetting("pollingTimeout", parseMs(newValue));
172
- return;
173
- case Options.SERVER_TIMEOUT:
174
- await settings.setLlamaSetting("serverTimeout", parseMs(newValue));
175
- return;
176
- }
177
- };
178
-
179
47
  export class CommandManager {
48
+ private readonly modelsMenu: ModelsMenu;
49
+
180
50
  constructor(
181
51
  private readonly serverManager: ServerManager,
182
52
  private readonly settings: LlamaSettingsManager,
183
- ) {}
53
+ ) {
54
+ this.modelsMenu = new ModelsMenu(serverManager);
55
+ }
184
56
 
185
57
  /**
186
58
  * Sets up the argument completions for the `/models` command
@@ -207,8 +79,8 @@ export class CommandManager {
207
79
  ctx: ExtensionCommandContext,
208
80
  pi: ExtensionAPI,
209
81
  ) {
210
- // Settings menu: no network round-trip needed, handle before any
211
- // server updates / unreachable-server notifications
82
+ // Settings menu: no provider re-registration needed (sortBy, timeouts,
83
+ // etc. don't affect the model registry)
212
84
  if (args === "settings") {
213
85
  await this.runSettingsMenu(ctx);
214
86
  return;
@@ -251,18 +123,12 @@ export class CommandManager {
251
123
  }
252
124
 
253
125
  // Interactive menu: show <name> (<server_url>)
254
- await this.runModelsMenu(ctx, pi);
126
+ await this.modelsMenu.show(ctx, pi);
255
127
  }
256
128
 
257
129
  /**
258
130
  * Runs the interactive settings menu for the scalar `llamaSettings`
259
131
  * fields. Enter/Space cycles the value under the cursor; Esc closes.
260
- *
261
- * Writes go to the global `~/.pi/agent/settings.json` via
262
- * `LlamaSettingsManager.setLlamaSetting()`; write errors are notified
263
- * and leave the dialog open with values unchanged. These settings
264
- * (reactToModelSelect, autoloadOnMessage, sortBy, timeouts) do not
265
- * require provider re-registration.
266
132
  */
267
133
  private async runSettingsMenu(ctx: ExtensionCommandContext): Promise<void> {
268
134
  if (ctx.mode !== "tui") {
@@ -273,36 +139,12 @@ export class CommandManager {
273
139
  return;
274
140
  }
275
141
 
276
- const items = await buildSettingsItems(this.settings);
277
-
278
- await ctx.ui.custom<void>(
279
- (_tui, _theme, _kb, done) =>
280
- new SettingsList(
281
- items,
282
- Math.min(items.length + 2, 15),
283
- getSettingsListTheme(),
284
- (id, newValue) => {
285
- applySettingChange(id, newValue, this.settings).catch(
286
- (err: unknown) => {
287
- const message = errorMessage(err);
288
- ctx.ui.notify(message, "error");
289
- },
290
- );
291
- },
292
- () => done(undefined),
293
- ),
294
- );
142
+ await SettingsEditor.show(ctx.ui, this.settings);
295
143
  }
296
144
 
297
145
  /**
298
- * Runs the interactive servers editor for `llamaSettings.servers`.
299
- * Enter on a server row drills into its field-edit submenu (URL/id/name);
300
- * a adds a new server (inline Input), d deletes (after confirmation);
301
- * Esc closes.
302
- *
303
- * Writes go to the global `~/.pi/agent/settings.json` via
304
- * `LlamaSettingsManager.setLlamaSetting()`; write errors are notified and
305
- * the editor stays open with the pre-mutation list. After closing,
146
+ * Runs the interactive servers editor for `llamaSettings.servers`
147
+ * (see `ServerSettingsList` for the editing semantics). After closing,
306
148
  * providers are re-registered so server changes apply immediately.
307
149
  */
308
150
  private async runServersEditor(
@@ -317,47 +159,17 @@ export class CommandManager {
317
159
  return;
318
160
  }
319
161
 
320
- const servers = await this.settings.getLlamaServers();
162
+ await ServerSettingsList.show(ctx.ui, this.settings);
321
163
 
322
- await ctx.ui.custom<void>(
323
- (tui, theme, keybindings, done) =>
324
- new ServerSettingsList({
325
- tui,
326
- theme,
327
- keybindings,
328
- servers,
329
- persist: (next) => this.settings.setLlamaSetting("servers", next),
330
- done: () => {
331
- done(undefined);
332
- // Re-register providers so the updated server list takes effect
333
- this.serverManager.update(pi);
334
- },
335
- onError: (message) => ctx.ui.notify(message, "error"),
336
- }),
337
- );
164
+ // Re-register providers so the updated server list takes effect
165
+ await this.serverManager.update(pi);
338
166
  }
339
167
 
340
168
  /**
341
169
  * Runs the interactive overrides editor for
342
- * `llamaSettings.servers[].overrides`: a SettingsList of servers drilling
343
- * down into each server's override entries (one row per pattern, with
344
- * add/delete support). Within a server's entry list: Enter drills into
345
- * the field-edit submenu; a adds, d deletes (after confirmation).
346
- *
347
- * Fields use a mix of finite (Enter to cycle) and infinite (Enter to
348
- * type) editing:
349
- *
350
- * - Pattern / costs (input, output, cacheRead, cacheWrite): infinite —
351
- * Enter opens an Input for typing.
352
- * - Capabilities: finite — Enter cycles between `text` and `text | image`.
353
- * - Reasoning: finite — Enter cycles between `true` and `false`.
354
- *
355
- * Servers themselves are not managed here — use `/models servers`.
356
- *
357
- * Writes go to the global `~/.pi/agent/settings.json` via
358
- * `LlamaSettingsManager.setLlamaSetting()`; write errors are notified and
359
- * leave the values unchanged. After closing, providers are
360
- * re-registered so new overrides take effect on the next request.
170
+ * `llamaSettings.servers[].overrides` (see `OverrideSettingsList` for
171
+ * the editing semantics). After closing, providers are re-registered
172
+ * so new overrides take effect on the next request.
361
173
  */
362
174
  private async runOverridesEditor(
363
175
  ctx: ExtensionCommandContext,
@@ -371,23 +183,10 @@ export class CommandManager {
371
183
  return;
372
184
  }
373
185
 
374
- const servers = await this.settings.getLlamaServers();
375
- await ctx.ui.custom<void>((tui, theme, keybindings, done) =>
376
- createOverrideSettingsList({
377
- tui,
378
- theme,
379
- keybindings,
380
- servers,
381
- persist: (next) => this.settings.setLlamaSetting("servers", next),
382
- done: () => {
383
- done(undefined);
384
- // Re-register providers so the updated overrides take effect
385
- this.serverManager.update(pi);
386
- },
387
- onError: (message) => ctx.ui.notify(message, "error"),
388
- onChanged: () => {}, // no per-change notification needed
389
- }),
390
- );
186
+ await OverrideSettingsList.show(ctx.ui, this.settings);
187
+
188
+ // Re-register providers so the updated overrides take effect
189
+ await this.serverManager.update(pi);
391
190
  }
392
191
 
393
192
  /**
@@ -396,233 +195,4 @@ export class CommandManager {
396
195
  private notifyNotFound(ctx: ExtensionCommandContext, url: string): void {
397
196
  ctx.ui.notify(`${PROVIDER_NAME} unreachable at ${url}`, "error");
398
197
  }
399
-
400
- /**
401
- * Runs the interactive model selection menu.
402
- */
403
- private async runModelsMenu(
404
- ctx: ExtensionCommandContext,
405
- pi: ExtensionAPI,
406
- ): Promise<void> {
407
- const event = await this.modelSelectionHandler(
408
- ctx,
409
- await this.serverManager.getAllModels(),
410
- );
411
-
412
- if (!event) return;
413
- const { action, model } = event;
414
-
415
- // Action: Cancel
416
- if (!action || action === Action.CANCEL) return;
417
-
418
- // Action: Info
419
- if (action === Action.INFO) {
420
- const info = await model.getInfo();
421
- ctx.ui.notify(`${info}`, "info");
422
- return;
423
- }
424
-
425
- // Action: Unload
426
- if (action === Action.UNLOAD) {
427
- await model.unload();
428
- ctx.ui.notify(`Unloaded ${model.name}`, "info");
429
- return;
430
- }
431
-
432
- // Action: Switch
433
- if (action === Action.SWITCH) {
434
- const { serverId } = model;
435
- const piModel = ctx.modelRegistry.find(serverId, model.id);
436
- if (!piModel)
437
- throw new Error(`Cannot find model ${model.name} in pi registry`);
438
-
439
- await pi.setModel(piModel);
440
- ctx.ui.notify(`Model ${model.name} ready`, "info");
441
- return;
442
- }
443
-
444
- // Actions: Load / Load & Switch / Retry
445
- const loadActions = [Action.LOAD, Action.LOAD_AND_SWITCH, Action.RETRY];
446
- if (loadActions.includes(action)) {
447
- ctx.ui.notify(`Loading ${model.name}...`, "info");
448
- // Mark the load as in-flight so session_before_switch can warn about
449
- // it (see EventManager.inflightModel for the coupling rationale)
450
- EventManager.inflightModel = model;
451
-
452
- // Subscribe to progress events; skip when the server is gone
453
- // (removed/edited away mid-load → getServer returns undefined)
454
- const server = this.serverManager.getServer(model);
455
- const cleanupProgress =
456
- server?.sseManager.subscribeToProgress(
457
- model.id,
458
- (percentage, stage) => {
459
- const stageText = stage ? ` (${stage})` : "";
460
- ctx.ui.notify(
461
- `Loading ${model.name}... [${percentage}%${stageText}]`,
462
- "info",
463
- );
464
- },
465
- ) ?? (() => {});
466
-
467
- const onSuccess = async () => {
468
- const { serverId } = model;
469
- const piModel = ctx.modelRegistry.find(serverId, model.id);
470
- if (!piModel)
471
- throw new Error(`Cannot find model ${model.name} in pi registry`);
472
-
473
- // Verify auth
474
- if ((await model.getStatus()) === Status.UNAUTHORIZED)
475
- throw new Error(
476
- `Unauthorized for ${model.name}. Use /login and add your API key.`,
477
- );
478
-
479
- // Verify failure
480
- if ((await model.getStatus()) === Status.FAILED)
481
- throw new Error(`Failed to load model ${model.name}`);
482
-
483
- // Select the model if asked
484
- if (action === Action.LOAD_AND_SWITCH) await pi.setModel(piModel);
485
-
486
- ctx.ui.notify(`Model ${model.name} ready`, "info");
487
- };
488
-
489
- const onFailure = (err: any) => {
490
- const message = errorMessage(err);
491
-
492
- try {
493
- ctx.ui.notify(message, "error");
494
- } catch {
495
- // ctx went stale between error and notification
496
- }
497
- };
498
-
499
- const onFinished = async () => {
500
- cleanupProgress();
501
- EventManager.resetInflightModel();
502
-
503
- // Re-scan providers to ensure accuracy of loaded models
504
- await this.serverManager.update(pi);
505
-
506
- // Force TUI refresh so Pi picks up the updated model states
507
- ctx.ui.setStatus(PROVIDER_NAME, " ");
508
- ctx.ui.setStatus(PROVIDER_NAME, undefined);
509
- };
510
-
511
- // Load the model without blocking the UI
512
- model.load().then(onSuccess).catch(onFailure).finally(onFinished);
513
- }
514
- }
515
-
516
- /**
517
- * Handles the menu for model selection.
518
- * Loops: select model → select action → handle action.
519
- *
520
- * Escape on actions menu goes back to model selection.
521
- * Escape on model selection exits.
522
- *
523
- * @returns The selected action and model
524
- */
525
- private async modelSelectionHandler(
526
- ctx: ExtensionCommandContext,
527
- models: BaseModel[],
528
- ): Promise<{ action: Action; model: BaseModel } | null> {
529
- while (true) {
530
- // Select the model
531
- const model = await this.selectModel(ctx, models);
532
- if (!model) return null;
533
-
534
- // Select the action
535
- const actions = await this.getActionsForModel(model);
536
- const action = await this.selectAction(ctx, model, actions);
537
- if (action === null) {
538
- // Escape key pressed => back to model selection
539
- continue;
540
- }
541
-
542
- // Return the selected action and model
543
- return { action, model };
544
- }
545
- }
546
-
547
- /**
548
- * Select a model from the list. Returns null if user cancels.
549
- *
550
- * @returns The model selected by the user
551
- */
552
- private async selectModel(
553
- ctx: ExtensionCommandContext,
554
- models: BaseModel[],
555
- ): Promise<BaseModel | null> {
556
- const labels = await Promise.all(
557
- models.map(async (model) => ({
558
- label: (await model.getLabel()).trim(),
559
- serverUrl: model.serverUrl,
560
- })),
561
- );
562
-
563
- // Count grapheme clusters (not UTF-16 code units) so emoji padding aligns visually
564
- const graphemeLength = (str: string) =>
565
- [...new Intl.Segmenter().segment(str)].length;
566
-
567
- // Decorate the label so the spacing makes it seem more like a table
568
- const maxLength = Math.max(
569
- ...labels.map(({ label }) => graphemeLength(label)),
570
- );
571
- const choices = labels.map(({ label, serverUrl }) => {
572
- const extraPadding = 2;
573
- const padLen = maxLength - graphemeLength(label) + extraPadding;
574
- return `${label}${" ".repeat(padLen)} [Server: ${serverUrl}]`;
575
- });
576
-
577
- const choice = await ctx.ui.select(`${PROVIDER_NAME} models:`, choices);
578
- if (!choice) return null;
579
- const idx = choices.indexOf(choice);
580
-
581
- return models[idx];
582
- }
583
-
584
- /**
585
- * Get available actions for a model based on its mode and status.
586
- *
587
- * @returns A mapping of actions for each status
588
- */
589
- private async getActionsForModel(model: BaseModel): Promise<Array<Action>> {
590
- const base = [Action.INFO, Action.CANCEL];
591
-
592
- const actions: Record<Status, Array<Action>> = {
593
- [Status.LOADED]:
594
- model.mode === Mode.ROUTER
595
- ? [Action.SWITCH, Action.UNLOAD, ...base]
596
- : [Action.SWITCH, ...base],
597
- [Status.LOADING]: [...base],
598
- [Status.FAILED]: [Action.RETRY, ...base],
599
- [Status.SLEEPING]:
600
- model.mode === Mode.ROUTER
601
- ? [Action.SWITCH, Action.UNLOAD, ...base]
602
- : [Action.SWITCH, ...base],
603
- [Status.UNLOADED]: [Action.LOAD_AND_SWITCH, Action.LOAD, ...base],
604
- [Status.UNAUTHORIZED]: [...base],
605
- };
606
-
607
- const status = await model.getStatus();
608
- return actions[status];
609
- }
610
-
611
- /**
612
- * Selects an action for a model.
613
- *
614
- * @returns The selected action
615
- */
616
- private async selectAction(
617
- ctx: ExtensionCommandContext,
618
- model: BaseModel,
619
- actions: Array<Action>,
620
- ): Promise<Action | null> {
621
- const labels = actions.map((a) => String(a));
622
- const choice = await ctx.ui.select(`${model.name}`, labels);
623
- if (!choice) return null;
624
-
625
- const idx = labels.indexOf(choice);
626
- return actions[idx];
627
- }
628
198
  }
@@ -11,8 +11,8 @@ import { ServerManager } from "./server";
11
11
 
12
12
  export class EventManager {
13
13
  /**
14
- * Model with a load currently in flight. Deliberately a class static
15
- * (REFACTOR.md §3.3): the load is started by CommandManager
14
+ * Model with a load currently in flight. Deliberately a class static:
15
+ * the load is started by CommandManager
16
16
  * (fire-and-forget from the /models editor) while the "session switched
17
17
  * mid-load" warning must be emitted here, from the session_before_switch
18
18
  * hook — a shared static is the least-plumbing bridge between the two
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { API_TYPE, PROVIDER_NAME, type SortBy } from "../constants";
2
+ import { ApiError } from "../api/client";
3
+ import { API_TYPE, PROVIDER_NAME } from "../constants";
3
4
  import { ServerStatus } from "../enums/serverStatus";
5
+ import type { SortBy } from "../interfaces/sortBy";
4
6
  import { BaseModel } from "../models/baseModel";
5
7
  import { Server } from "../server";
6
8
  import type { LlamaSettingsManager } from "./settings";
@@ -80,7 +82,28 @@ export class ServerManager {
80
82
  try {
81
83
  await server.initialize();
82
84
  await this.registerProvider(server, pi);
83
- } catch {
85
+ } catch (err) {
86
+ if (err instanceof ApiError && err.type === "authentication") {
87
+ // Register the provider with an empty model list so the user can
88
+ // still configure the API key via `/login` or `auth.json`. On the
89
+ // next scan the provider will re-initialize and discover models.
90
+ // Don't add to `failedUrls` — the server IS reachable, auth just
91
+ // isn't configured yet, so the health indicator should stay green.
92
+ const message = [
93
+ "[pi-llama-cpp]",
94
+ `Server at '${server.baseUrl}' requires a valid API key.`,
95
+ "Configure the key via `/login` or in `~/.pi/agent/auth.json`.",
96
+ ].join("\n");
97
+ this.warnings.push(message);
98
+ pi.registerProvider(server.providerId, {
99
+ name: server.providerName,
100
+ baseUrl: server.apiBaseUrl,
101
+ api: API_TYPE,
102
+ apiKey: server.getApiKey(),
103
+ models: [],
104
+ });
105
+ continue;
106
+ }
84
107
  this.failedUrls.push(server.baseUrl);
85
108
  continue;
86
109
  }
@@ -128,12 +151,13 @@ export class ServerManager {
128
151
  }
129
152
 
130
153
  /**
131
- * Creates a Pi provider for the given server
154
+ * Creates a Pi provider for the given server.
132
155
  *
133
156
  * @param server The server
157
+ * @param pi The Pi API
134
158
  */
135
159
  private async registerProvider(server: Server, pi: ExtensionAPI) {
136
- const { baseUrl, models, providerId, providerName } = server;
160
+ const { apiBaseUrl, models, providerId, providerName } = server;
137
161
  const apiKey = server.getApiKey();
138
162
  const modelConfigs = await Promise.all(
139
163
  models.map((m) => m.toProviderConfig()),
@@ -141,7 +165,7 @@ export class ServerManager {
141
165
 
142
166
  pi.registerProvider(providerId, {
143
167
  name: providerName,
144
- baseUrl: baseUrl,
168
+ baseUrl: apiBaseUrl,
145
169
  api: API_TYPE,
146
170
  apiKey: apiKey,
147
171
  models: modelConfigs,