pi-llama-cpp 0.14.0 → 0.16.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 (62) hide show
  1. package/README.md +52 -13
  2. package/package.json +3 -3
  3. package/src/api/client.ts +59 -22
  4. package/src/constants.ts +5 -0
  5. package/src/enums/mode.ts +1 -0
  6. package/src/index.ts +2 -1
  7. package/src/interfaces/endpoints/models.ts +6 -0
  8. package/src/interfaces/endpoints/props.ts +14 -0
  9. package/src/interfaces/settings.ts +6 -0
  10. package/src/managers/command/models.ts +12 -6
  11. package/src/managers/command.ts +6 -1
  12. package/src/managers/server.ts +30 -38
  13. package/src/managers/settings.ts +23 -3
  14. package/src/models/legacyModel.ts +3 -3
  15. package/src/models/llamaSwapModel.ts +90 -0
  16. package/src/models/routerModel.ts +2 -2
  17. package/src/models/singleModel.ts +2 -2
  18. package/src/server.ts +42 -15
  19. package/src/sse/manager.ts +2 -2
  20. package/src/ui/dialog/confirm.ts +1 -1
  21. package/src/ui/dialog/input.ts +1 -1
  22. package/src/ui/editors/editorOptions.ts +8 -1
  23. package/src/ui/editors/itemBuilder.ts +2 -6
  24. package/src/ui/editors/listEditor.ts +24 -15
  25. package/src/ui/editors/override/entryEditor.ts +105 -23
  26. package/src/ui/editors/override/fields/base.ts +53 -0
  27. package/src/ui/editors/override/fields/capabilities.ts +36 -0
  28. package/src/ui/editors/override/fields/cost.ts +64 -0
  29. package/src/ui/editors/override/fields/index.ts +68 -0
  30. package/src/ui/editors/override/fields/numeric.ts +55 -0
  31. package/src/ui/editors/override/fields/pattern.ts +24 -0
  32. package/src/ui/editors/override/fields/reasoning.ts +33 -0
  33. package/src/ui/editors/override/itemBuilder.ts +4 -4
  34. package/src/ui/editors/server/fields.ts +2 -2
  35. package/src/ui/editors/server/itemBuilder.ts +2 -3
  36. package/src/ui/editors/server/serverEditor.ts +58 -22
  37. package/src/ui/editors/server/utils.ts +50 -0
  38. package/src/ui/settings/index.ts +11 -0
  39. package/src/ui/strings.ts +17 -0
  40. package/src/utils/credentialResolver.ts +110 -0
  41. package/src/utils/health.ts +2 -2
  42. package/src/utils/urlResolver.ts +1 -1
  43. package/tests/{commandManager.test.ts → command/commandManager.test.ts} +80 -9
  44. package/tests/{events.test.ts → events/events.test.ts} +6 -6
  45. package/tests/mocks.ts +4 -0
  46. package/tests/{legacyModel.test.ts → models/legacyModel.test.ts} +5 -5
  47. package/tests/models/llamaSwapModel.test.ts +327 -0
  48. package/tests/{routerModel.test.ts → models/routerModel.test.ts} +5 -5
  49. package/tests/{singleModel.test.ts → models/singleModel.test.ts} +5 -5
  50. package/tests/{health.test.ts → server/health.test.ts} +4 -4
  51. package/tests/{server.test.ts → server/server.test.ts} +57 -7
  52. package/tests/{serverManager.test.ts → server/serverManager.test.ts} +4 -4
  53. package/tests/{settings.test.ts → settings/settings.test.ts} +198 -355
  54. package/tests/{settingsStore.test.ts → settings/settingsStore.test.ts} +1 -1
  55. package/tests/{sseManager.test.ts → sse/sseManager.test.ts} +2 -2
  56. package/tests/{dialog.test.ts → ui/dialog.test.ts} +5 -4
  57. package/tests/{overrides.test.ts → ui/overrides.test.ts} +9 -42
  58. package/tests/utils/credentialResolver.test.ts +66 -0
  59. package/tests/utils/urlResolver.test.ts +116 -0
  60. package/src/ui/editors/override/fields.ts +0 -294
  61. package/src/ui/editors/override/handlers.ts +0 -118
  62. package/src/ui/editors/server/handlers.ts +0 -32
@@ -0,0 +1,36 @@
1
+ import type { ModelOverride } from "../../../../interfaces/settings";
2
+ import { FIELDS } from "../../../strings";
3
+ import { OverrideEntry } from "../entry";
4
+ import type { InputValidator } from "./base";
5
+ import { OverrideField } from "./base";
6
+
7
+ /** The override's `capabilities` list (finite: fixed label options). */
8
+ export class CapabilitiesField extends OverrideField {
9
+ readonly id = "capabilities";
10
+ readonly type = "finite";
11
+ readonly field = FIELDS.capabilities;
12
+ override readonly options = ["text", "text | image"];
13
+
14
+ displayValue(entry: OverrideEntry): string {
15
+ return entry.override.capabilities?.join(" | ") ?? "text";
16
+ }
17
+
18
+ readonly validate: InputValidator = (raw) =>
19
+ this.options?.includes(raw) ? raw : null;
20
+
21
+ apply(override: ModelOverride, value: string): void {
22
+ override.capabilities =
23
+ value === "text | image"
24
+ ? ["text", "image"]
25
+ : value === "text"
26
+ ? ["text"]
27
+ : undefined;
28
+ }
29
+
30
+ /** Appears in the summary only when non-empty. */
31
+ override summaryPart(override: ModelOverride): string | null {
32
+ return override.capabilities?.length
33
+ ? `capabilities: ${override.capabilities.join(",")}`
34
+ : null;
35
+ }
36
+ }
@@ -0,0 +1,64 @@
1
+ import type { ModelCost, ModelCostRates } from "@earendil-works/pi-ai";
2
+ import type { ModelOverride } from "../../../../interfaces/settings";
3
+ import type { Field } from "../../../strings";
4
+ import { OverrideEntry } from "../entry";
5
+ import type { InputValidator } from "./base";
6
+ import { OverrideField } from "./base";
7
+ import { parseNonNegative } from "./numeric";
8
+
9
+ /**
10
+ * One `ModelCostRates` key of the override's `cost` object (absorbs the
11
+ * former `CostFieldDefFactory` and the standalone `parseCostValue` /
12
+ * `applyCostFieldValue` helpers): owns the cost parse and immutable
13
+ * apply in the same place as its display format.
14
+ */
15
+ export class CostField extends OverrideField {
16
+ readonly type = "input";
17
+ override readonly field: Field;
18
+
19
+ constructor(
20
+ readonly id: string,
21
+ private readonly key: keyof ModelCostRates,
22
+ field: Field,
23
+ /** Short term used in the entry-row summary (e.g. `cache read`). */
24
+ private readonly summaryTerm: string,
25
+ ) {
26
+ super();
27
+ this.field = field;
28
+ }
29
+
30
+ displayValue(entry: OverrideEntry): string {
31
+ return String(entry.override.cost?.[this.key] ?? 0);
32
+ }
33
+
34
+ readonly validate: InputValidator = (raw) => {
35
+ const parsed = parseNonNegative(raw);
36
+ return parsed === null ? null : String(parsed);
37
+ };
38
+
39
+ apply(override: ModelOverride, value: string): void {
40
+ const nextCost = this.applyCost(override.cost, Number(value));
41
+ if (nextCost) override.cost = nextCost;
42
+ else delete override.cost;
43
+ }
44
+
45
+ /** Returns a copy of `cost` with this field set to `value`. A `0` (or
46
+ * non-finite) value removes the field — unset cost fields default to
47
+ * zero at read time — and when no fields remain, returns `undefined`
48
+ * so the whole `cost` object can be dropped. Immutable. */
49
+ private applyCost(
50
+ cost: ModelOverride["cost"],
51
+ value: number,
52
+ ): Partial<ModelCost> | undefined {
53
+ const next = { ...cost };
54
+ if (isFinite(value) && value > 0) next[this.key] = value;
55
+ else delete next[this.key];
56
+ return Object.keys(next).length > 0 ? next : undefined;
57
+ }
58
+
59
+ /** Appears in the summary only when a non-zero cost is set. */
60
+ override summaryPart(override: ModelOverride): string | null {
61
+ const value = override.cost?.[this.key];
62
+ return value ? `${this.summaryTerm}: $${value}` : null;
63
+ }
64
+ }
@@ -0,0 +1,68 @@
1
+ import type { ModelCostRates } from "@earendil-works/pi-ai";
2
+ import type { ModelOverride } from "../../../../interfaces/settings";
3
+ import { FIELDS } from "../../../strings";
4
+ import { OverrideField } from "./base";
5
+ import { CapabilitiesField } from "./capabilities";
6
+ import { CostField } from "./cost";
7
+ import { NumericField } from "./numeric";
8
+ import { PatternField } from "./pattern";
9
+ import { ReasoningField } from "./reasoning";
10
+
11
+ /** Cost field specs: [ModelCostRates key, FIELDS entry, summary term] */
12
+ const COST_FIELDS: [
13
+ keyof ModelCostRates,
14
+ (
15
+ | typeof FIELDS.inputCost
16
+ | typeof FIELDS.outputCost
17
+ | typeof FIELDS.cacheReadCost
18
+ | typeof FIELDS.cacheWriteCost
19
+ ),
20
+ string,
21
+ ][] = [
22
+ ["input", FIELDS.inputCost, "input"],
23
+ ["output", FIELDS.outputCost, "output"],
24
+ ["cacheRead", FIELDS.cacheReadCost, "cache read"],
25
+ ["cacheWrite", FIELDS.cacheWriteCost, "cache write"],
26
+ ];
27
+
28
+ /**
29
+ * The field registry for model override entries: the complete list of
30
+ * editable fields (order defines the row order in the settings list)
31
+ * plus id-based lookup. Throws on an unknown id — the registry is
32
+ * static, so a miss is a programming error.
33
+ */
34
+ export class OverrideFields {
35
+ static readonly all: OverrideField[] = [
36
+ new PatternField(),
37
+ ...COST_FIELDS.map(
38
+ ([key, field, term]) => new CostField(`cost.${key}`, key, field, term),
39
+ ),
40
+ new CapabilitiesField(),
41
+ new ReasoningField(),
42
+ new NumericField("contextSize", FIELDS.contextSize),
43
+ new NumericField("maxTokens", FIELDS.maxTokens),
44
+ ];
45
+
46
+ static byId(id: string): OverrideField {
47
+ const field = this.all.find((f) => f.id === id);
48
+ if (!field) throw new Error(`Unknown override field: ${id}`);
49
+ return field;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Builds the entry-row summary shown in the override entry list
55
+ * (`input: $0.2, output: $0.6, cache read: $0.01, …`), composed from the
56
+ * {@link OverrideFields} registry — adding a field to the registry makes
57
+ * it show up here too, so the summary can't lag the edit menu. Fields
58
+ * with unset/zero values are omitted for brevity (each field decides via
59
+ * {@link OverrideField.summaryPart}); `—` when nothing is set.
60
+ */
61
+ export class OverrideSummary {
62
+ static of(override: ModelOverride): string {
63
+ const parts = OverrideFields.all
64
+ .map((field) => field.summaryPart(override))
65
+ .filter((part): part is string => part !== null);
66
+ return parts.length > 0 ? parts.join(", ") : "—";
67
+ }
68
+ }
@@ -0,0 +1,55 @@
1
+ import type { ModelOverride } from "../../../../interfaces/settings";
2
+ import type { Field } from "../../../strings";
3
+ import { OverrideEntry } from "../entry";
4
+ import type { InputValidator } from "./base";
5
+ import { OverrideField } from "./base";
6
+
7
+ /** Parses a non-negative number; blank input means zero (unset fields
8
+ * default to zero at read time). Returns `null` when invalid. */
9
+ export function parseNonNegative(raw: string): number | null {
10
+ const n = Number(raw.trim());
11
+ return isFinite(n) && n >= 0 ? n : null;
12
+ }
13
+
14
+ /**
15
+ * Non-negative numeric field (`contextSize`, `maxTokens`): keeps the
16
+ * display format, validator and apply logic in a single place so the
17
+ * three can't drift (e.g. the validator accepting `0` while the apply
18
+ * drops it — unset fields default to zero at read time, so all render
19
+ * as `0`).
20
+ */
21
+ export class NumericField extends OverrideField {
22
+ readonly type = "input";
23
+ override readonly field: Field;
24
+
25
+ constructor(
26
+ readonly id: "maxTokens" | "contextSize",
27
+ field: Field,
28
+ ) {
29
+ super();
30
+ this.field = field;
31
+ }
32
+
33
+ displayValue(entry: OverrideEntry): string {
34
+ return String(entry.override[this.id] ?? 0);
35
+ }
36
+
37
+ /** Accepts finite, non-negative numbers (blank means zero). */
38
+ readonly validate: InputValidator = (raw) => {
39
+ const n = parseNonNegative(raw);
40
+ return n === null ? null : String(n);
41
+ };
42
+
43
+ /** Applies the committed value; non-positive values remove the key. */
44
+ apply(override: ModelOverride, value: string): void {
45
+ const n = Number(value);
46
+ if (isFinite(n) && n > 0) override[this.id] = n;
47
+ else delete override[this.id];
48
+ }
49
+
50
+ /** Appears in the summary only when set. */
51
+ override summaryPart(override: ModelOverride): string | null {
52
+ const value = override[this.id];
53
+ return value === undefined ? null : `${this.id}: ${value}`;
54
+ }
55
+ }
@@ -0,0 +1,24 @@
1
+ import { FIELDS } from "../../../strings";
2
+ import { OverrideEntry } from "../entry";
3
+ import type { InputValidator } from "./base";
4
+ import { OverrideField } from "./base";
5
+
6
+ /**
7
+ * The entry's pattern key. Renames are handled by the editor
8
+ * (`OverrideEntryListEditor.applyFieldChange`), so {@link apply}
9
+ * inherits the base no-op.
10
+ */
11
+ export class PatternField extends OverrideField {
12
+ readonly id = "pattern";
13
+ readonly type = "input";
14
+ readonly field = FIELDS.pattern;
15
+
16
+ displayValue(entry: OverrideEntry): string {
17
+ return entry.pattern;
18
+ }
19
+
20
+ readonly validate: InputValidator = (raw) => {
21
+ const trimmed = raw.trim();
22
+ return trimmed.length > 0 ? trimmed : null;
23
+ };
24
+ }
@@ -0,0 +1,33 @@
1
+ import type { ModelOverride } from "../../../../interfaces/settings";
2
+ import { FIELDS } from "../../../strings";
3
+ import { OverrideEntry } from "../entry";
4
+ import type { InputValidator } from "./base";
5
+ import { OverrideField } from "./base";
6
+
7
+ /** The override's `reasoning` flag (finite: true/false, default true). */
8
+ export class ReasoningField extends OverrideField {
9
+ readonly id = "reasoning";
10
+ readonly type = "finite";
11
+ readonly field = FIELDS.reasoning;
12
+ override readonly options = ["true", "false"];
13
+
14
+ displayValue(entry: OverrideEntry): string {
15
+ return entry.override.reasoning === false ? "false" : "true";
16
+ }
17
+
18
+ readonly validate: InputValidator = (raw) =>
19
+ this.options?.includes(raw) ? raw : null;
20
+
21
+ apply(override: ModelOverride, value: string): void {
22
+ if (value === "true") override.reasoning = true;
23
+ else if (value === "false") override.reasoning = false;
24
+ else delete override.reasoning;
25
+ }
26
+
27
+ /** Appears in the summary only when explicitly set. */
28
+ override summaryPart(override: ModelOverride): string | null {
29
+ return override.reasoning === undefined
30
+ ? null
31
+ : `reasoning: ${override.reasoning}`;
32
+ }
33
+ }
@@ -2,7 +2,8 @@ import type { SettingItem } from "@earendil-works/pi-tui";
2
2
  import { FieldMessages, TITLES } from "../../strings";
3
3
  import { ItemBuilder } from "../itemBuilder";
4
4
  import { OverrideEntry } from "./entry";
5
- import { OverrideFields, type OverrideField } from "./fields";
5
+ import type { OverrideField } from "./fields/base";
6
+ import { OverrideFields } from "./fields/index";
6
7
 
7
8
  /**
8
9
  * Builds `SettingItem` objects for override entry field submenus.
@@ -11,7 +12,7 @@ export class OverrideItemBuilder extends ItemBuilder<
11
12
  OverrideEntry,
12
13
  OverrideField
13
14
  > {
14
- protected get fields(): readonly OverrideField[] {
15
+ protected override get fields(): readonly OverrideField[] {
15
16
  return OverrideFields.all;
16
17
  }
17
18
 
@@ -20,9 +21,8 @@ export class OverrideItemBuilder extends ItemBuilder<
20
21
  * an `InputDialog` submenu (validating against the field definition)
21
22
  * for "input" fields.
22
23
  */
23
- protected decorate(
24
+ protected override decorate(
24
25
  def: OverrideField,
25
- entry: OverrideEntry,
26
26
  base: SettingItem,
27
27
  ): SettingItem {
28
28
  if (def.type === "finite") {
@@ -26,7 +26,7 @@ class UrlField extends ServerField {
26
26
  readonly field = FIELDS.serverUrl;
27
27
  readonly validate = ServerUrl.parse;
28
28
 
29
- currentValue(server: LlamaServer): string {
29
+ override currentValue(server: LlamaServer): string {
30
30
  return server.url;
31
31
  }
32
32
 
@@ -51,7 +51,7 @@ class OptionalTextField extends ServerField {
51
51
  this.field = field;
52
52
  }
53
53
 
54
- currentValue(server: LlamaServer): string {
54
+ override currentValue(server: LlamaServer): string {
55
55
  return server[this.id] ?? "";
56
56
  }
57
57
 
@@ -10,7 +10,7 @@ import { ServerDisplay } from "./utils";
10
10
  * Builds `SettingItem` objects for server rows and their field-edit submenus.
11
11
  */
12
12
  export class ServerItemBuilder extends ItemBuilder<LlamaServer, ServerField> {
13
- protected get fields(): readonly ServerField[] {
13
+ protected override get fields(): readonly ServerField[] {
14
14
  return ServerFields.all;
15
15
  }
16
16
 
@@ -18,9 +18,8 @@ export class ServerItemBuilder extends ItemBuilder<LlamaServer, ServerField> {
18
18
  * Adds the field-edit submenu: an `InputDialog` validating against the
19
19
  * field definition.
20
20
  */
21
- protected decorate(
21
+ protected override decorate(
22
22
  def: ServerField,
23
- server: LlamaServer,
24
23
  base: SettingItem,
25
24
  ): SettingItem {
26
25
  return {
@@ -3,11 +3,12 @@ import type { SettingItem, SettingsList } from "@earendil-works/pi-tui";
3
3
  import { SERVER_TIMEOUT } from "../../../constants";
4
4
  import type { LlamaServer } from "../../../interfaces/settings";
5
5
  import type { LlamaSettingsManager } from "../../../managers/settings";
6
- import { TITLES } from "../../strings";
6
+ import { ServerIds } from "../../../utils/serverIds";
7
+ import { TITLES, authRequiredMessage } from "../../strings";
7
8
  import type { ServerSettingsListOptions } from "../editorOptions";
8
9
  import { ListEditor } from "../listEditor";
9
10
  import { SettingsListFactory } from "../settingsListFactory";
10
- import { ServerEntryMutator } from "./handlers";
11
+ import { ServerFields } from "./fields";
11
12
  import { ServerItemBuilder } from "./itemBuilder";
12
13
  import { ServerDisplay } from "./utils";
13
14
  import { ServerWizard } from "./wizard";
@@ -58,26 +59,42 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
58
59
  done: () => done(undefined),
59
60
  onError: (message) => ui.notify(message, "error"),
60
61
  serverTimeout,
62
+ authResolver: async (server) => {
63
+ // Mirror Server.getApiKey(): use ServerIds.resolve to get
64
+ // the provider ID (customId or llama-server=<url>), then
65
+ // resolve the key from the credential store.
66
+ return settings.resolveApiKey(
67
+ ServerIds.resolve(server.url, server.id),
68
+ );
69
+ },
70
+ ui,
61
71
  }),
62
72
  );
63
73
  }
64
74
 
65
75
  // -- abstract hooks -------------------------------------------------------
66
76
 
67
- protected async buildSettingsList(): Promise<SettingsList> {
77
+ protected override async buildSettingsList(): Promise<SettingsList> {
68
78
  const builder = new ServerItemBuilder(this.dialogs);
69
79
  const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
70
- const healthEmojis = await Promise.all(
71
- this.options.servers.map((server) =>
72
- ServerDisplay.healthEmoji(server.url, serverTimeout),
73
- ),
80
+ // Run auth + health probes in parallel; ⛔ wins if auth fails,
81
+ // otherwise fall back to the health emoji.
82
+ const probes = await Promise.all(
83
+ this.options.servers.map(async (server) => {
84
+ const apiKey = (await this.options.authResolver?.(server)) ?? "";
85
+ const [authEmoji, healthEmoji] = await Promise.all([
86
+ ServerDisplay.authEmoji(server.url, apiKey, serverTimeout),
87
+ ServerDisplay.healthEmoji(server.url, serverTimeout),
88
+ ]);
89
+ return authEmoji || healthEmoji;
90
+ }),
74
91
  );
75
92
  const items: SettingItem[] = this.options.servers.map((server, i) =>
76
93
  builder.buildRow(
77
94
  server,
78
95
  i,
79
96
  (field, value) => this.handleFieldChange(field, value),
80
- healthEmojis[i],
97
+ probes[i],
81
98
  // Opening delegates input to the submenu; closing flushes a
82
99
  // rebuild deferred by a field commit (see commitFieldChange)
83
100
  (open) => this.trackSubmenu(open),
@@ -92,10 +109,13 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
92
109
  /** Handle a field commit from a server row's submenu. */
93
110
  private handleFieldChange(field: string, value: string): void {
94
111
  const idx = this.selectedIndex;
95
- const next = new ServerEntryMutator(
96
- this.options.servers,
97
- idx,
98
- ).applyFieldChange(field, value);
112
+ const server = this.options.servers[idx];
113
+ const updated = server
114
+ ? ServerFields.byId(field).apply(server, value)
115
+ : server;
116
+ const next = server
117
+ ? this.options.servers.map((s, i) => (i === idx ? updated : s))
118
+ : this.options.servers;
99
119
 
100
120
  this.commitFieldChange(next, idx, () => {
101
121
  // Refresh the row's suffix in place; the full rebuild (row labels
@@ -111,7 +131,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
111
131
  });
112
132
  }
113
133
 
114
- protected beginAdd(): void {
134
+ protected override beginAdd(): void {
115
135
  const wizard = new ServerWizard(this.dialogs, (dialog) =>
116
136
  this.openDialog(dialog),
117
137
  );
@@ -124,7 +144,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
124
144
  );
125
145
  }
126
146
 
127
- protected deleteSelected(): void {
147
+ protected override deleteSelected(): void {
128
148
  const idx = this.selectedIndex;
129
149
  const next = this.options.servers.filter((_, i) => i !== idx);
130
150
  void this.persistSnapshot(next, () => {
@@ -132,21 +152,17 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
132
152
  });
133
153
  }
134
154
 
135
- protected readonly emptyHintKey = "emptyServers" as const;
136
-
137
- protected getCurrentCount(): number {
138
- return this.options.servers.length;
139
- }
155
+ protected override readonly emptyHintKey = "emptyServers" as const;
140
156
 
141
- protected getRowId(index: number): string {
157
+ protected override getRowId(index: number): string {
142
158
  return `server-${index}`;
143
159
  }
144
160
 
145
- protected getRowLabel(index: number): string {
161
+ protected override getRowLabel(index: number): string {
146
162
  return this.options.servers[index]?.url ?? "";
147
163
  }
148
164
 
149
- protected get deleteTitle(): string {
165
+ protected override get deleteTitle(): string {
150
166
  return TITLES.deleteServer;
151
167
  }
152
168
 
@@ -157,5 +173,25 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
157
173
  await this.persistSnapshot(next, () => {
158
174
  void this.rebuildList(next.length - 1);
159
175
  });
176
+
177
+ // Warn the user if the newly added server requires an API key
178
+ await this.warnIfAuthRequired(server);
179
+ }
180
+
181
+ /**
182
+ * Probes the server to check if it requires an API key. If so, notifies
183
+ * the user so they know to configure one via `/login` or `auth.json`.
184
+ */
185
+ private async warnIfAuthRequired(server: LlamaServer): Promise<void> {
186
+ const ui = this.options.ui;
187
+ if (!ui) return;
188
+
189
+ const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
190
+ const apiKey = (await this.options.authResolver?.(server)) ?? "";
191
+
192
+ if (await ServerDisplay.requiresApiKey(server.url, apiKey, serverTimeout)) {
193
+ const providerId = ServerIds.resolve(server.url, server.id);
194
+ ui.notify(authRequiredMessage(server.url, providerId), "warning");
195
+ }
160
196
  }
161
197
  }
@@ -1,8 +1,12 @@
1
+ import { ENDPOINT_PREFIX } from "../../../constants";
1
2
  import { ServerStatus } from "../../../enums/serverStatus";
2
3
  import type { LlamaServer } from "../../../interfaces/settings";
3
4
  import { checkServerHealth } from "../../../utils/health";
4
5
  import { ServerIds } from "../../../utils/serverIds";
5
6
 
7
+ /** Emoji shown when a server requires an API key (401 on /v1/models). */
8
+ const UNAUTHORIZED_EMOJI = "⛔";
9
+
6
10
  /**
7
11
  * Display concerns for a server row in the top-level list: the dim
8
12
  * `(...)` suffix after the URL and the health-status icon.
@@ -33,6 +37,52 @@ export class ServerDisplay {
33
37
  static async healthEmoji(url: string, timeout: number): Promise<string> {
34
38
  return SERVER_STATUS_ICONS[await checkServerHealth(url, timeout)];
35
39
  }
40
+
41
+ /**
42
+ * Checks if the server requires an API key by probing `/v1/models`.
43
+ *
44
+ * @param url - The server URL to check
45
+ * @param apiKey - The API key to send (may be a placeholder)
46
+ * @param timeout - Maximum time (ms) to wait for the request
47
+ * @returns `true` if the server responded with 401, `false` otherwise
48
+ */
49
+ static async requiresApiKey(
50
+ url: string,
51
+ apiKey: string,
52
+ timeout: number,
53
+ ): Promise<boolean> {
54
+ try {
55
+ const response = await fetch(`${url}${ENDPOINT_PREFIX}/models`, {
56
+ signal: AbortSignal.timeout(timeout),
57
+ headers: { Authorization: `Bearer ${apiKey}` },
58
+ });
59
+ return response.status === 401;
60
+ } catch {
61
+ // Timeout or network error — the server is unreachable, so no auth
62
+ // indicator is needed (the health emoji will cover that).
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Probes the server's `/v1/models` endpoint to check if authentication
69
+ * is required. Returns ⛔ when the server responds with 401, empty string
70
+ * otherwise.
71
+ *
72
+ * @param url - The server URL to check
73
+ * @param apiKey - The API key to send (may be a placeholder)
74
+ * @param timeout - Maximum time (ms) to wait for the request
75
+ * @returns The emoji indicator (⛔ if 401, "" otherwise)
76
+ */
77
+ static async authEmoji(
78
+ url: string,
79
+ apiKey: string,
80
+ timeout: number,
81
+ ): Promise<string> {
82
+ return (await this.requiresApiKey(url, apiKey, timeout))
83
+ ? UNAUTHORIZED_EMOJI
84
+ : "";
85
+ }
36
86
  }
37
87
 
38
88
  /**
@@ -26,6 +26,7 @@ export enum Options {
26
26
  SORT_BY = "sortBy",
27
27
  POLLING_TIMEOUT = "pollingTimeout",
28
28
  SERVER_TIMEOUT = "serverTimeout",
29
+ SHOW_SERVER_URLS = "showServerUrls",
29
30
  }
30
31
 
31
32
  // ─── Constants ──────────────────────────────────────────────────────────────
@@ -110,6 +111,13 @@ export const buildSettingsItems = async (
110
111
  currentValue: formatMs(serverTimeout),
111
112
  values: SERVER_PRESETS.map(formatMs),
112
113
  },
114
+ {
115
+ id: Options.SHOW_SERVER_URLS,
116
+ label: "Show server URLs",
117
+ description: "Show [Server: <url>] in /models (next open)",
118
+ currentValue: (await settings.resolveShowServerUrls()) ? "on" : "off",
119
+ values: ["on", "off"],
120
+ },
113
121
  ];
114
122
  };
115
123
 
@@ -139,6 +147,9 @@ export const applySettingChange = async (
139
147
  case Options.SERVER_TIMEOUT:
140
148
  await settings.setLlamaSetting("serverTimeout", parseMs(newValue));
141
149
  return;
150
+ case Options.SHOW_SERVER_URLS:
151
+ await settings.setLlamaSetting("showServerUrls", newValue === "on");
152
+ return;
142
153
  }
143
154
  };
144
155
 
package/src/ui/strings.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+
1
3
  import { LLAMA_SERVER_URL } from "../constants";
2
4
 
3
5
  /**
@@ -129,3 +131,18 @@ export const HINTS = {
129
131
  /** Row description for a server in the overrides editor */
130
132
  overrideServerRow: "Enter: edit this server's override entries",
131
133
  } as const;
134
+
135
+ /**
136
+ * Generates a warning message for servers that require an API key.
137
+ * Centralized so both the runtime manager and the editor wizard use
138
+ * identical wording.
139
+ */
140
+ export const authRequiredMessage = (
141
+ baseUrl: string,
142
+ providerId: string,
143
+ ): string =>
144
+ [
145
+ "[pi-llama-cpp]",
146
+ `Server at '${baseUrl}' requires a valid API key.`,
147
+ `Configure the key via '/login ${providerId}' or in '${getAgentDir()}/auth.json'.`,
148
+ ].join("\n");