pi-llama-cpp 0.14.0 → 0.15.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 (47) hide show
  1. package/README.md +12 -10
  2. package/package.json +3 -3
  3. package/src/constants.ts +5 -0
  4. package/src/index.ts +2 -1
  5. package/src/interfaces/settings.ts +6 -0
  6. package/src/managers/command/models.ts +9 -2
  7. package/src/managers/command.ts +6 -1
  8. package/src/managers/server.ts +30 -38
  9. package/src/managers/settings.ts +14 -2
  10. package/src/server.ts +6 -9
  11. package/src/sse/manager.ts +2 -2
  12. package/src/ui/editors/editorOptions.ts +8 -1
  13. package/src/ui/editors/itemBuilder.ts +2 -6
  14. package/src/ui/editors/listEditor.ts +24 -15
  15. package/src/ui/editors/override/entryEditor.ts +97 -15
  16. package/src/ui/editors/override/fields/base.ts +53 -0
  17. package/src/ui/editors/override/fields/capabilities.ts +36 -0
  18. package/src/ui/editors/override/fields/cost.ts +64 -0
  19. package/src/ui/editors/override/fields/index.ts +68 -0
  20. package/src/ui/editors/override/fields/numeric.ts +55 -0
  21. package/src/ui/editors/override/fields/pattern.ts +24 -0
  22. package/src/ui/editors/override/fields/reasoning.ts +33 -0
  23. package/src/ui/editors/override/itemBuilder.ts +3 -6
  24. package/src/ui/editors/server/itemBuilder.ts +1 -5
  25. package/src/ui/editors/server/serverEditor.ts +51 -15
  26. package/src/ui/editors/server/utils.ts +50 -0
  27. package/src/ui/settings/index.ts +11 -0
  28. package/src/ui/strings.ts +17 -0
  29. package/src/utils/health.ts +2 -1
  30. package/src/utils/urlResolver.ts +1 -1
  31. package/tests/{commandManager.test.ts → command/commandManager.test.ts} +80 -9
  32. package/tests/{events.test.ts → events/events.test.ts} +6 -6
  33. package/tests/mocks.ts +2 -0
  34. package/tests/{legacyModel.test.ts → models/legacyModel.test.ts} +5 -5
  35. package/tests/{routerModel.test.ts → models/routerModel.test.ts} +5 -5
  36. package/tests/{singleModel.test.ts → models/singleModel.test.ts} +5 -5
  37. package/tests/{health.test.ts → server/health.test.ts} +2 -2
  38. package/tests/{server.test.ts → server/server.test.ts} +5 -5
  39. package/tests/{serverManager.test.ts → server/serverManager.test.ts} +4 -4
  40. package/tests/{settings.test.ts → settings/settings.test.ts} +174 -133
  41. package/tests/{settingsStore.test.ts → settings/settingsStore.test.ts} +1 -1
  42. package/tests/{sseManager.test.ts → sse/sseManager.test.ts} +2 -2
  43. package/tests/{dialog.test.ts → ui/dialog.test.ts} +5 -4
  44. package/tests/{overrides.test.ts → ui/overrides.test.ts} +9 -42
  45. package/src/ui/editors/override/fields.ts +0 -294
  46. package/src/ui/editors/override/handlers.ts +0 -118
  47. package/src/ui/editors/server/handlers.ts +0 -32
@@ -1,11 +1,11 @@
1
1
  import type { SettingItem, SettingsList } from "@earendil-works/pi-tui";
2
+ import type { LlamaServer, ModelOverride } from "../../../interfaces/settings";
2
3
  import { FIELDS, FieldMessages, HINTS, TITLES } from "../../strings";
3
4
  import type { OverrideSettingsListOptions } from "../editorOptions";
4
5
  import { ListEditor } from "../listEditor";
5
6
  import { SettingsListFactory } from "../settingsListFactory";
6
7
  import { OverrideEntry } from "./entry";
7
- import { OverrideFields, OverrideSummary } from "./fields";
8
- import { OverrideEntryMutator } from "./handlers";
8
+ import { OverrideFields, OverrideSummary } from "./fields/index";
9
9
  import { OverrideItemBuilder } from "./itemBuilder";
10
10
 
11
11
  /**
@@ -39,13 +39,13 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
39
39
 
40
40
  protected buildSettingsList(): SettingsList {
41
41
  const builder = new OverrideItemBuilder(this.dialogs);
42
- const items: SettingItem[] = this.mutator.entries().map((entry, i) => ({
42
+ const items: SettingItem[] = this.entries().map((entry, i) => ({
43
43
  id: `entry-${i}`,
44
44
  label: entry.pattern,
45
45
  description: HINTS.overrideEntryRow,
46
46
  currentValue: OverrideSummary.of(entry.override),
47
47
  submenu: (_cv, done) => {
48
- const current = this.mutator.entries()[i] ?? entry;
48
+ const current = this.entries()[i] ?? entry;
49
49
  const fieldItems = builder.buildFieldItems(current);
50
50
  const fieldList = SettingsListFactory.create(
51
51
  fieldItems,
@@ -75,7 +75,7 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
75
75
  field: string,
76
76
  value: string,
77
77
  ): void {
78
- const result = this.mutator.applyFieldChange(entryIndex, field, value);
78
+ const result = this.applyFieldChange(entryIndex, field, value);
79
79
  if (!result) return;
80
80
 
81
81
  this.commitFieldChange(
@@ -121,7 +121,7 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
121
121
 
122
122
  protected deleteSelected(): void {
123
123
  const idx = this.selectedIndex;
124
- const next = this.mutator.removeEntry(idx);
124
+ const next = this.removeEntry(idx);
125
125
  void this.persistSnapshot(next, () => {
126
126
  this.rebuildList(idx);
127
127
  this.options.onChanged?.();
@@ -131,8 +131,8 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
131
131
 
132
132
  protected readonly emptyHintKey = "emptyOverrideEntries" as const;
133
133
 
134
- protected getCurrentCount(): number {
135
- return this.mutator.entries().length;
134
+ protected getCount(): number {
135
+ return this.entries().length;
136
136
  }
137
137
 
138
138
  protected getRowId(index: number): string {
@@ -140,7 +140,7 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
140
140
  }
141
141
 
142
142
  protected getRowLabel(index: number): string {
143
- return this.mutator.entries()[index]?.pattern ?? "";
143
+ return this.entries()[index]?.pattern ?? "";
144
144
  }
145
145
 
146
146
  protected get deleteTitle(): string {
@@ -149,16 +149,98 @@ export class OverrideEntryListEditor extends ListEditor<OverrideSettingsListOpti
149
149
 
150
150
  // -- helpers ---------------------------------------------------------------
151
151
 
152
- /** Fresh mutator over the current servers snapshot — `persistSnapshot`
153
- * replaces `options.servers` in place, so instances can't be cached. */
154
- private get mutator(): OverrideEntryMutator {
155
- return new OverrideEntryMutator(this.options.servers, this.serverIndex);
152
+ /** The server's override entries, in map iteration order. */
153
+ private entries(): OverrideEntry[] {
154
+ return OverrideEntry.listFrom(this.options.servers[this.serverIndex]);
155
+ }
156
+
157
+ /** Handles a field commit from an override entry's submenu. Returns
158
+ * `null` when the entry doesn't exist (stale index — no-op). */
159
+ private applyFieldChange(
160
+ entryIndex: number,
161
+ field: string,
162
+ value: string,
163
+ ): { next: LlamaServer[]; updatedOverride: ModelOverride } | null {
164
+ const entry = this.entries()[entryIndex];
165
+ if (!entry) return null;
166
+
167
+ // Pattern field: rename the pattern key, keep the override object
168
+ if (field === "pattern") {
169
+ return {
170
+ next: this.updateEntry(entryIndex, value, entry.override),
171
+ updatedOverride: entry.override,
172
+ };
173
+ }
174
+
175
+ // Other fields: let the field definition apply itself
176
+ const updatedOverride: ModelOverride = { ...entry.override };
177
+ OverrideFields.byId(field).apply(updatedOverride, value);
178
+
179
+ return {
180
+ next: this.updateEntry(entryIndex, entry.pattern, updatedOverride),
181
+ updatedOverride,
182
+ };
183
+ }
184
+
185
+ /** Adds a new override entry with a uniquified pattern. */
186
+ private addEntry(pattern: string): LlamaServer[] {
187
+ const server = this.options.servers[this.serverIndex];
188
+ if (!server) return this.options.servers;
189
+
190
+ const existing = new Set(Object.keys(server.overrides ?? {}));
191
+ let uniquePattern = pattern;
192
+ let n = 2;
193
+ while (existing.has(uniquePattern)) {
194
+ uniquePattern = `${pattern}-${n++}`;
195
+ }
196
+
197
+ return this.withServer((s) => ({
198
+ ...s,
199
+ overrides: { ...s.overrides, [uniquePattern]: {} },
200
+ }));
201
+ }
202
+
203
+ /** Removes the entry at `entryIndex`. */
204
+ private removeEntry(entryIndex: number): LlamaServer[] {
205
+ return this.withServer((s) => ({
206
+ ...s,
207
+ overrides: Object.fromEntries(
208
+ Object.entries(s.overrides ?? {}).filter((_, j) => j !== entryIndex),
209
+ ),
210
+ }));
211
+ }
212
+
213
+ /** Returns the servers with the target server replaced by
214
+ * `fn(server)`. Immutable. */
215
+ private withServer(fn: (server: LlamaServer) => LlamaServer): LlamaServer[] {
216
+ return this.options.servers.map((server, i) =>
217
+ i === this.serverIndex ? fn(server) : server,
218
+ );
219
+ }
220
+
221
+ /** Replaces the entry at `entryIndex` with `pattern → override` in a
222
+ * single mutation — used both for renaming the pattern and for editing
223
+ * its fields. The entry keeps its position in the map's iteration
224
+ * order. Immutable. */
225
+ private updateEntry(
226
+ entryIndex: number,
227
+ pattern: string,
228
+ override: ModelOverride,
229
+ ): LlamaServer[] {
230
+ return this.withServer((s) => ({
231
+ ...s,
232
+ overrides: Object.fromEntries(
233
+ Object.entries(s.overrides ?? {}).map(([k, v], j) =>
234
+ j === entryIndex ? [pattern, override] : [k, v],
235
+ ),
236
+ ),
237
+ }));
156
238
  }
157
239
 
158
240
  private async saveAdd(pattern: string): Promise<void> {
159
- const next = this.mutator.addEntry(pattern);
241
+ const next = this.addEntry(pattern);
160
242
  await this.persistSnapshot(next, () => {
161
- this.rebuildList(this.mutator.entries().length - 1);
243
+ this.rebuildList(this.entries().length - 1);
162
244
  this.options.onChanged?.();
163
245
  this.options.tui.requestRender();
164
246
  });
@@ -0,0 +1,53 @@
1
+ import type { ModelOverride } from "../../../../interfaces/settings";
2
+ import { SettingField } from "../../settingField";
3
+ import { OverrideEntry } from "../entry";
4
+
5
+ /** Type of a field: "input" for free-text, "finite" for fixed options */
6
+ export type FieldType = "input" | "finite";
7
+
8
+ /** Validation function for input fields */
9
+ export type InputValidator = (raw: string) => string | null;
10
+
11
+ /**
12
+ * Abstract base class for one editable field of a model override entry
13
+ * (Template Method): each subclass owns its display format, validation
14
+ * and apply logic in a single place, so the parse/format round-trips
15
+ * can't drift. Extends the shared {@link SettingField} base (id, label,
16
+ * description, placeholder from the FIELDS registry).
17
+ */
18
+ export abstract class OverrideField extends SettingField<OverrideEntry> {
19
+ /** Field type: "input" for free-text, "finite" for fixed options. */
20
+ abstract readonly type: FieldType;
21
+
22
+ /**
23
+ * Canonical display label for this field — the single source of truth
24
+ * for both the field rows' `currentValue` and the post-commit refresh
25
+ * of an open field submenu, so the two can't drift.
26
+ */
27
+ abstract displayValue(entry: OverrideEntry): string;
28
+
29
+ /** Current value shown in the row — defaults to `displayValue`. */
30
+ currentValue(entry: OverrideEntry): string {
31
+ return this.displayValue(entry);
32
+ }
33
+
34
+ /** For "input" fields: validation function for committed values. */
35
+ abstract readonly validate: InputValidator;
36
+
37
+ /** For "finite" fields: the fixed options (undefined for "input"). */
38
+ readonly options: readonly string[] | undefined = undefined;
39
+
40
+ /** Applies a committed value to the override object. The pattern key
41
+ * rename is handled separately by the mutator (default: no-op). */
42
+ apply(_override: ModelOverride, _value: string): void {
43
+ // no-op — override in subclasses that need to mutate the override
44
+ }
45
+
46
+ /** One `term: value` part of the entry-row summary (see
47
+ * {@link OverrideSummary.of}), or `null` when the field shouldn't
48
+ * appear (unset/zero values — each field decides). The pattern is the
49
+ * row label, so it never contributes a part; the default is `null`. */
50
+ summaryPart(_override: ModelOverride): string | null {
51
+ return null;
52
+ }
53
+ }
@@ -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.
@@ -20,11 +21,7 @@ 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
- def: OverrideField,
25
- entry: OverrideEntry,
26
- base: SettingItem,
27
- ): SettingItem {
24
+ protected decorate(def: OverrideField, base: SettingItem): SettingItem {
28
25
  if (def.type === "finite") {
29
26
  return { ...base, values: [...(def.options ?? [])] };
30
27
  }
@@ -18,11 +18,7 @@ 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(
22
- def: ServerField,
23
- server: LlamaServer,
24
- base: SettingItem,
25
- ): SettingItem {
21
+ protected decorate(def: ServerField, base: SettingItem): SettingItem {
26
22
  return {
27
23
  ...base,
28
24
  submenu: this.dialogs.inputSubmenu(
@@ -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,6 +59,15 @@ 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
  }
@@ -67,17 +77,24 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
67
77
  protected 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
@@ -134,10 +154,6 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
134
154
 
135
155
  protected readonly emptyHintKey = "emptyServers" as const;
136
156
 
137
- protected getCurrentCount(): number {
138
- return this.options.servers.length;
139
- }
140
-
141
157
  protected getRowId(index: number): string {
142
158
  return `server-${index}`;
143
159
  }
@@ -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
  }