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
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { SettingsStore } from "../src/utils/settingsStore";
2
+ import { SettingsStore } from "../../src/utils/settingsStore";
3
3
 
4
4
  const mockReadFile = vi.hoisted(() => vi.fn());
5
5
  const mockWriteFile = vi.hoisted(() => vi.fn());
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { SSEManager } from "../src/sse/manager";
3
- import { createMockServer } from "./mocks";
2
+ import { SSEManager } from "../../src/sse/manager";
3
+ import { createMockServer } from "../mocks";
4
4
 
5
5
  /**
6
6
  * Builds an SSEManager bound to a stub server, mirroring how
@@ -5,10 +5,10 @@ import {
5
5
  type TUI,
6
6
  } from "@earendil-works/pi-tui";
7
7
  import { beforeEach, describe, expect, it, vi } from "vitest";
8
- import { ConfirmDialog } from "../src/ui/dialog/confirm";
9
- import { InputDialog } from "../src/ui/dialog/input";
10
- import { OverrideSettingsList } from "../src/ui/editors/override/overrideList";
11
- import { ServerSettingsList } from "../src/ui/editors/server/serverEditor";
8
+ import { ConfirmDialog } from "../../src/ui/dialog/confirm";
9
+ import { InputDialog } from "../../src/ui/dialog/input";
10
+ import { OverrideSettingsList } from "../../src/ui/editors/override/overrideList";
11
+ import { ServerSettingsList } from "../../src/ui/editors/server/serverEditor";
12
12
 
13
13
  beforeEach(() => {
14
14
  initTheme();
@@ -139,6 +139,7 @@ describe("ServerSettingsList add wizard", () => {
139
139
  persist,
140
140
  done,
141
141
  onError,
142
+ authResolver: () => Promise.resolve("sk-placeholder"),
142
143
  });
143
144
  return { editor, persist, done, onError };
144
145
  };
@@ -1,15 +1,14 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { FALLBACK_CTX } from "../src/constants";
3
- import type { LlamaServer, ModelOverride } from "../src/interfaces/settings";
4
- import { SingleModel } from "../src/models/singleModel";
5
- import { Server } from "../src/server";
6
- import { OverrideEntry } from "../src/ui/editors/override/entry";
2
+ import { FALLBACK_CTX } from "../../src/constants";
3
+ import type { ModelOverride } from "../../src/interfaces/settings";
4
+ import { SingleModel } from "../../src/models/singleModel";
5
+ import { Server } from "../../src/server";
6
+ import { OverrideEntry } from "../../src/ui/editors/override/entry";
7
7
  import {
8
8
  OverrideFields,
9
9
  OverrideSummary,
10
- } from "../src/ui/editors/override/fields";
11
- import { OverrideEntryMutator } from "../src/ui/editors/override/handlers";
12
- import { createMockServer, mockRpc } from "./mocks";
10
+ } from "../../src/ui/editors/override/fields";
11
+ import { createMockServer, mockRpc } from "../mocks";
13
12
 
14
13
  beforeEach(() => {
15
14
  mockRpc.mockReset();
@@ -293,41 +292,9 @@ describe("OverrideSummary.of", () => {
293
292
  });
294
293
  });
295
294
 
296
- // ─── override entry list helpers (overrideEntryEditor) ────────────────────
297
-
298
- describe("override entry list helpers", () => {
299
- const servers = [
300
- { url: "http://a", overrides: { "a-1": { reasoning: false } } },
301
- { url: "http://b" },
302
- ] as LlamaServer[];
303
-
304
- it("addEntry should append a pattern → override entry", () => {
305
- const next = new OverrideEntryMutator(servers, 0).addEntry("a-2", {
306
- contextSize: 4096,
307
- });
308
- expect(next[0].overrides).toEqual({
309
- "a-1": { reasoning: false },
310
- "a-2": { contextSize: 4096 },
311
- });
312
- });
313
-
314
- it("pattern renames should replace in place without reordering", () => {
315
- const mutator = new OverrideEntryMutator(servers, 0);
316
- const result = mutator.applyFieldChange(0, "pattern", "a-renamed");
317
- expect(result).not.toBeNull();
318
- expect(Object.keys(result!.next[0].overrides!)).toEqual(["a-renamed"]);
319
- expect(result!.next[0].overrides!["a-renamed"]).toEqual({
320
- reasoning: false,
321
- });
322
- expect(result!.next[1]).toBe(servers[1]);
323
- });
324
-
325
- it("removeEntry should drop only the targeted entry", () => {
326
- const withTwo = new OverrideEntryMutator(servers, 0).addEntry("a-2");
327
- const next = new OverrideEntryMutator(withTwo, 0).removeEntry(0);
328
- expect(Object.keys(next[0].overrides!)).toEqual(["a-2"]);
329
- });
295
+ // ─── CostField tests ──────────────────────────────────────────────────────
330
296
 
297
+ describe("CostField validation and apply", () => {
331
298
  it("CostField.validate should accept blank, non-negative numbers; reject the rest", () => {
332
299
  const cost = OverrideFields.byId("cost.input");
333
300
  expect(cost.validate("")).toBe("0");
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CredentialResolver } from "../../src/utils/credentialResolver";
3
+
4
+ describe("CredentialResolver", () => {
5
+ const resolver = new CredentialResolver();
6
+
7
+ describe("resolve", () => {
8
+ it("returns placeholder for empty key", () => {
9
+ expect(resolver.resolve("")).toBe("sk-placeholder");
10
+ expect(resolver.resolve(undefined as any)).toBe("sk-placeholder");
11
+ });
12
+
13
+ it("returns literal key as-is", () => {
14
+ expect(resolver.resolve("sk-abc123")).toBe("sk-abc123");
15
+ expect(resolver.resolve("plain-key-without-dollars")).toBe(
16
+ "plain-key-without-dollars",
17
+ );
18
+ });
19
+
20
+ it("resolves escape sequences $$", () => {
21
+ expect(resolver.resolve("$$literal")).toBe("$literal");
22
+ });
23
+
24
+ it("resolves escape sequences $!", () => {
25
+ expect(resolver.resolve("$!bang")).toBe("!bang");
26
+ });
27
+
28
+ it("resolves $VAR from credential.env", () => {
29
+ expect(resolver.resolve("$MY_KEY", { MY_KEY: "from-env" })).toBe(
30
+ "from-env",
31
+ );
32
+ });
33
+
34
+ it("resolves ${VAR} from credential.env", () => {
35
+ expect(resolver.resolve("${MY_KEY}", { MY_KEY: "from-env" })).toBe(
36
+ "from-env",
37
+ );
38
+ });
39
+
40
+ it("resolves $VAR from process.env as fallback", () => {
41
+ process.env.TEST_PROCESS_VAR = "process-value";
42
+ expect(resolver.resolve("$TEST_PROCESS_VAR")).toBe("process-value");
43
+ delete process.env.TEST_PROCESS_VAR;
44
+ });
45
+
46
+ it("prioritizes credential.env over process.env", () => {
47
+ process.env.PREF_KEY = "process-value";
48
+ expect(
49
+ resolver.resolve("$PREF_KEY", { PREF_KEY: "credential-value" }),
50
+ ).toBe("credential-value");
51
+ delete process.env.PREF_KEY;
52
+ });
53
+
54
+ it("returns placeholder when env var is missing", () => {
55
+ expect(resolver.resolve("$UNSET_VAR")).toBe("sk-placeholder");
56
+ expect(resolver.resolve("${UNSET_VAR}", { OTHER_KEY: "value" })).toBe(
57
+ "sk-placeholder",
58
+ );
59
+ });
60
+
61
+ it("returns placeholder for invalid env var syntax", () => {
62
+ expect(resolver.resolve("$123invalid")).toBe("sk-placeholder");
63
+ expect(resolver.resolve("$has-dash")).toBe("sk-placeholder");
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,116 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { UrlResolver } from "../../src/utils/urlResolver";
3
+
4
+ function createResolver(
5
+ getLlamaSettings: () => Promise<Record<string, unknown>>,
6
+ getMergedSettings: () => Promise<Record<string, unknown>>,
7
+ ) {
8
+ return new UrlResolver({ getLlamaSettings, getMergedSettings });
9
+ }
10
+
11
+ describe("UrlResolver", () => {
12
+ beforeEach(() => {
13
+ delete process.env.LLAMA_SERVER_URL;
14
+ });
15
+
16
+ describe("resolveUrls", () => {
17
+ it("falls back to default when nothing is configured", async () => {
18
+ const resolver = createResolver(
19
+ () => Promise.resolve({}),
20
+ () => Promise.resolve({}),
21
+ );
22
+
23
+ const result = await resolver.resolveUrls();
24
+ expect(result).toEqual(["http://127.0.0.1:8080"]);
25
+ });
26
+
27
+ it("prioritizes env variable over all config sources", async () => {
28
+ process.env.LLAMA_SERVER_URL = "http://env:9090";
29
+ const resolver = createResolver(
30
+ () =>
31
+ Promise.resolve({
32
+ servers: [{ url: "http://servers:8080" }],
33
+ }),
34
+ () => Promise.resolve({ llamaServerUrl: "http://legacy:7070" }),
35
+ );
36
+
37
+ const result = await resolver.resolveUrls();
38
+ expect(result).toEqual(["http://env:9090"]);
39
+ });
40
+
41
+ it("prioritizes llamaSettings.servers over legacy llamaServerUrl", async () => {
42
+ const resolver = createResolver(
43
+ () =>
44
+ Promise.resolve({
45
+ servers: [{ url: "http://servers:8080" }],
46
+ }),
47
+ () => Promise.resolve({ llamaServerUrl: "http://legacy:7070" }),
48
+ );
49
+
50
+ const result = await resolver.resolveUrls();
51
+ expect(result).toEqual(["http://servers:8080"]);
52
+ });
53
+
54
+ it("falls back to legacy when servers is empty", async () => {
55
+ const resolver = createResolver(
56
+ () => Promise.resolve({ servers: [] }),
57
+ () => Promise.resolve({ llamaServerUrl: "http://legacy:7070" }),
58
+ );
59
+
60
+ const result = await resolver.resolveUrls();
61
+ expect(result).toEqual(["http://legacy:7070"]);
62
+ });
63
+
64
+ it("returns empty arrays from settings when values are falsy", async () => {
65
+ const resolver = createResolver(
66
+ () => Promise.resolve({ servers: undefined }),
67
+ () => Promise.resolve({ llamaServerUrl: undefined }),
68
+ );
69
+
70
+ const result = await resolver.resolveUrls();
71
+ expect(result).toEqual(["http://127.0.0.1:8080"]);
72
+ });
73
+ });
74
+
75
+ describe("warnings", () => {
76
+ it("collects warnings for invalid env URLs", async () => {
77
+ process.env.LLAMA_SERVER_URL = "invalid-url;http://good:8080";
78
+ const resolver = createResolver(
79
+ () => Promise.resolve({}),
80
+ () => Promise.resolve({}),
81
+ );
82
+
83
+ await resolver.resolveUrls();
84
+ expect(resolver.takeWarnings()).toEqual([
85
+ "Ignoring invalid server URL 'invalid-url' (needs http(s)://)",
86
+ ]);
87
+ });
88
+
89
+ it("collects warnings for invalid server URLs", async () => {
90
+ const resolver = createResolver(
91
+ () =>
92
+ Promise.resolve({
93
+ servers: [{ url: "bad-url" }, { url: "http://good:8080" }],
94
+ }),
95
+ () => Promise.resolve({}),
96
+ );
97
+
98
+ await resolver.resolveUrls();
99
+ expect(resolver.takeWarnings()).toEqual([
100
+ "Ignoring invalid server URL 'bad-url' (needs http(s)://)",
101
+ ]);
102
+ });
103
+
104
+ it("clears warnings after takeWarnings()", async () => {
105
+ process.env.LLAMA_SERVER_URL = "bad;http://good:8080";
106
+ const resolver = createResolver(
107
+ () => Promise.resolve({}),
108
+ () => Promise.resolve({}),
109
+ );
110
+
111
+ await resolver.resolveUrls();
112
+ expect(resolver.takeWarnings()).toHaveLength(1);
113
+ expect(resolver.takeWarnings()).toEqual([]);
114
+ });
115
+ });
116
+ });
@@ -1,294 +0,0 @@
1
- import type { ModelCost, ModelCostRates } from "@earendil-works/pi-ai";
2
- import type { ModelOverride } from "../../../interfaces/settings";
3
- import { FIELDS, type Field } from "../../strings";
4
- import { SettingField } from "../settingField";
5
- import { OverrideEntry } from "./entry";
6
-
7
- /** Type of a field: "input" for free-text, "finite" for fixed options */
8
- export type FieldType = "input" | "finite";
9
-
10
- /** Validation function for input fields */
11
- export type InputValidator = (raw: string) => string | null;
12
-
13
- /**
14
- * Abstract base class for one editable field of a model override entry
15
- * (Template Method): each subclass owns its display format, validation
16
- * and apply logic in a single place, so the parse/format round-trips
17
- * can't drift. Extends the shared {@link SettingField} base (id, label,
18
- * description, placeholder from the FIELDS registry).
19
- */
20
- export abstract class OverrideField extends SettingField<OverrideEntry> {
21
- /** Field type: "input" for free-text, "finite" for fixed options. */
22
- abstract readonly type: FieldType;
23
-
24
- /**
25
- * Canonical display label for this field — the single source of truth
26
- * for both the field rows' `currentValue` and the post-commit refresh
27
- * of an open field submenu, so the two can't drift.
28
- */
29
- abstract displayValue(entry: OverrideEntry): string;
30
-
31
- /** Current value shown in the row — same rendering as the post-commit
32
- * refresh of an open field submenu, so the two can't drift. */
33
- currentValue(entry: OverrideEntry): string {
34
- return this.displayValue(entry);
35
- }
36
-
37
- /** For "input" fields: validation function for committed values. */
38
- abstract readonly validate: InputValidator;
39
-
40
- /** For "finite" fields: the fixed options (undefined for "input"). */
41
- readonly options: readonly string[] | undefined = undefined;
42
-
43
- /** Applies a committed value to the override object. The pattern key
44
- * rename is handled separately by the mutator. */
45
- abstract apply(override: ModelOverride, value: string): void;
46
-
47
- /** One `term: value` part of the entry-row summary (see
48
- * {@link OverrideSummary.of}), or `null` when the field shouldn't
49
- * appear (unset/zero values — each field decides). The pattern is the
50
- * row label, so it never contributes a part; the default is `null`. */
51
- summaryPart(_override: ModelOverride): string | null {
52
- return null;
53
- }
54
-
55
- /** Parses a non-negative number; blank input means zero (unset fields
56
- * default to zero at read time). Returns `null` when invalid. Shared
57
- * by the cost and numeric fields so their validation can't drift. */
58
- protected static parseNonNegative(raw: string): number | null {
59
- const n = Number(raw.trim());
60
- return isFinite(n) && n >= 0 ? n : null;
61
- }
62
- }
63
-
64
- /**
65
- * The entry's pattern key. The rename itself is handled by the mutator
66
- * (`OverrideEntryMutator`); {@link apply} is a no-op.
67
- */
68
- class PatternField extends OverrideField {
69
- readonly id = "pattern";
70
- readonly type = "input";
71
- readonly field = FIELDS.pattern;
72
-
73
- displayValue(entry: OverrideEntry): string {
74
- return entry.pattern;
75
- }
76
-
77
- readonly validate: InputValidator = (raw) => {
78
- const trimmed = raw.trim();
79
- return trimmed.length > 0 ? trimmed : null;
80
- };
81
-
82
- apply(): void {
83
- // The pattern IS the map key — renames go through the mutator.
84
- }
85
- }
86
-
87
- /**
88
- * One `ModelCostRates` key of the override's `cost` object (absorbs the
89
- * former `CostFieldDefFactory` and the standalone `parseCostValue` /
90
- * `applyCostFieldValue` helpers): owns the cost parse and immutable
91
- * apply in the same place as its display format.
92
- */
93
- class CostField extends OverrideField {
94
- readonly type = "input";
95
- override readonly field: Field;
96
-
97
- constructor(
98
- readonly id: string,
99
- private readonly key: keyof ModelCostRates,
100
- field: Field,
101
- /** Short term used in the entry-row summary (e.g. `cache read`). */
102
- private readonly summaryTerm: string,
103
- ) {
104
- super();
105
- this.field = field;
106
- }
107
-
108
- displayValue(entry: OverrideEntry): string {
109
- return String(entry.override.cost?.[this.key] ?? 0);
110
- }
111
-
112
- readonly validate: InputValidator = (raw) => {
113
- const parsed = OverrideField.parseNonNegative(raw);
114
- return parsed === null ? null : String(parsed);
115
- };
116
-
117
- apply(override: ModelOverride, value: string): void {
118
- const nextCost = this.applyCost(override.cost, Number(value));
119
- if (nextCost) override.cost = nextCost;
120
- else delete override.cost;
121
- }
122
-
123
- /** Returns a copy of `cost` with this field set to `value`. A `0` (or
124
- * non-finite) value removes the field — unset cost fields default to
125
- * zero at read time — and when no fields remain, returns `undefined`
126
- * so the whole `cost` object can be dropped. Immutable. */
127
- private applyCost(
128
- cost: ModelOverride["cost"],
129
- value: number,
130
- ): Partial<ModelCost> | undefined {
131
- const next = { ...cost };
132
- if (isFinite(value) && value > 0) next[this.key] = value;
133
- else delete next[this.key];
134
- return Object.keys(next).length > 0 ? next : undefined;
135
- }
136
-
137
- /** Appears in the summary only when a non-zero cost is set. */
138
- override summaryPart(override: ModelOverride): string | null {
139
- const value = override.cost?.[this.key];
140
- return value ? `${this.summaryTerm}: $${value}` : null;
141
- }
142
- }
143
-
144
- /**
145
- * Non-negative numeric field (`contextSize`, `maxTokens`): keeps the
146
- * display format, validator and apply logic in a single place so the
147
- * three can't drift (e.g. the validator accepting `0` while the apply
148
- * drops it — unset fields default to zero at read time, so all render
149
- * as `0`).
150
- */
151
- class NumericField extends OverrideField {
152
- readonly type = "input";
153
- override readonly field: Field;
154
-
155
- constructor(
156
- readonly id: "maxTokens" | "contextSize",
157
- field: Field,
158
- ) {
159
- super();
160
- this.field = field;
161
- }
162
-
163
- displayValue(entry: OverrideEntry): string {
164
- return String(entry.override[this.id] ?? 0);
165
- }
166
-
167
- /** Accepts finite, non-negative numbers (blank means zero). */
168
- readonly validate: InputValidator = (raw) => {
169
- const n = OverrideField.parseNonNegative(raw);
170
- return n === null ? null : String(n);
171
- };
172
-
173
- /** Applies the committed value; non-positive values remove the key. */
174
- apply(override: ModelOverride, value: string): void {
175
- const n = Number(value);
176
- if (isFinite(n) && n > 0) override[this.id] = n;
177
- else delete override[this.id];
178
- }
179
-
180
- /** Appears in the summary only when set. */
181
- override summaryPart(override: ModelOverride): string | null {
182
- const value = override[this.id];
183
- return value === undefined ? null : `${this.id}: ${value}`;
184
- }
185
- }
186
-
187
- /** The override's `capabilities` list (finite: fixed label options). */
188
- class CapabilitiesField extends OverrideField {
189
- readonly id = "capabilities";
190
- readonly type = "finite";
191
- readonly field = FIELDS.capabilities;
192
- override readonly options = ["text", "text | image"];
193
-
194
- displayValue(entry: OverrideEntry): string {
195
- return entry.override.capabilities?.join(" | ") ?? "text";
196
- }
197
-
198
- readonly validate: InputValidator = (raw) =>
199
- this.options?.includes(raw) ? raw : null;
200
-
201
- apply(override: ModelOverride, value: string): void {
202
- override.capabilities =
203
- value === "text | image"
204
- ? ["text", "image"]
205
- : value === "text"
206
- ? ["text"]
207
- : undefined;
208
- }
209
-
210
- /** Appears in the summary only when non-empty. */
211
- override summaryPart(override: ModelOverride): string | null {
212
- return override.capabilities?.length
213
- ? `capabilities: ${override.capabilities.join(",")}`
214
- : null;
215
- }
216
- }
217
-
218
- /** The override's `reasoning` flag (finite: true/false, default true). */
219
- class ReasoningField extends OverrideField {
220
- readonly id = "reasoning";
221
- readonly type = "finite";
222
- readonly field = FIELDS.reasoning;
223
- override readonly options = ["true", "false"];
224
-
225
- displayValue(entry: OverrideEntry): string {
226
- return entry.override.reasoning === false ? "false" : "true";
227
- }
228
-
229
- readonly validate: InputValidator = (raw) =>
230
- this.options?.includes(raw) ? raw : null;
231
-
232
- apply(override: ModelOverride, value: string): void {
233
- if (value === "true") override.reasoning = true;
234
- else if (value === "false") override.reasoning = false;
235
- else delete override.reasoning;
236
- }
237
-
238
- /** Appears in the summary only when explicitly set. */
239
- override summaryPart(override: ModelOverride): string | null {
240
- return override.reasoning === undefined
241
- ? null
242
- : `reasoning: ${override.reasoning}`;
243
- }
244
- }
245
-
246
- /** Cost field specs: [ModelCostRates key, FIELDS entry, summary term] */
247
- const COST_FIELDS: [keyof ModelCostRates, Field, string][] = [
248
- ["input", FIELDS.inputCost, "input"],
249
- ["output", FIELDS.outputCost, "output"],
250
- ["cacheRead", FIELDS.cacheReadCost, "cache read"],
251
- ["cacheWrite", FIELDS.cacheWriteCost, "cache write"],
252
- ];
253
-
254
- /**
255
- * The field registry for model override entries: the complete list of
256
- * editable fields (order defines the row order in the settings list)
257
- * plus id-based lookup. Throws on an unknown id — the registry is
258
- * static, so a miss is a programming error.
259
- */
260
- export class OverrideFields {
261
- static readonly all: OverrideField[] = [
262
- new PatternField(),
263
- ...COST_FIELDS.map(
264
- ([key, field, term]) => new CostField(`cost.${key}`, key, field, term),
265
- ),
266
- new CapabilitiesField(),
267
- new ReasoningField(),
268
- new NumericField("contextSize", FIELDS.contextSize),
269
- new NumericField("maxTokens", FIELDS.maxTokens),
270
- ];
271
-
272
- static byId(id: string): OverrideField {
273
- const field = this.all.find((f) => f.id === id);
274
- if (!field) throw new Error(`Unknown override field: ${id}`);
275
- return field;
276
- }
277
- }
278
-
279
- /**
280
- * Builds the entry-row summary shown in the override entry list
281
- * (`input: $0.2, output: $0.6, cache read: $0.01, …`), composed from the
282
- * {@link OverrideFields} registry — adding a field to the registry makes
283
- * it show up here too, so the summary can't lag the edit menu. Fields
284
- * with unset/zero values are omitted for brevity (each field decides via
285
- * {@link OverrideField.summaryPart}); `—` when nothing is set.
286
- */
287
- export class OverrideSummary {
288
- static of(override: ModelOverride): string {
289
- const parts = OverrideFields.all
290
- .map((field) => field.summaryPart(override))
291
- .filter((part): part is string => part !== null);
292
- return parts.length > 0 ? parts.join(", ") : "—";
293
- }
294
- }