pi-llama-cpp 0.11.0 → 0.13.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.
@@ -1,3 +1,4 @@
1
+ import type { ModelCost } from "@earendil-works/pi-ai";
1
2
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
3
  import { FALLBACK_CTX, POLLING_INTERVAL } from "../constants";
3
4
  import { Mode } from "../enums/mode";
@@ -57,18 +58,24 @@ export abstract class BaseModel {
57
58
 
58
59
  /**
59
60
  * Whether the model is a reasoning model.
60
- * Currently always returns true since there's no way to detect this from llama-server.
61
+ * An override's `reasoning` wins; otherwise defaults to `true`, since
62
+ * there's no way to detect this from llama-server.
61
63
  */
62
64
  get reasoning(): boolean {
63
- return true;
65
+ return this.server.findOverrideForModel(this.id)?.reasoning ?? true;
64
66
  }
65
67
 
66
68
  /**
67
- * Detects the capabilities of the model
69
+ * Detects the capabilities of the model.
70
+ * An override's `capabilities` fully replaces detection; otherwise the
71
+ * model's modalities are probed from the server.
68
72
  *
69
73
  * @returns An array of capabilities, as expected by Pi
70
74
  */
71
75
  async getCapabilities(): Promise<("text" | "image")[]> {
76
+ const overridden = this.server.findOverrideForModel(this.id)?.capabilities;
77
+ if (overridden) return overridden;
78
+
72
79
  try {
73
80
  // When loaded, this works alright
74
81
  const { modalities } = await this.server.fetchModelProps(this.id);
@@ -115,9 +122,16 @@ export abstract class BaseModel {
115
122
  /**
116
123
  * Gets the context size of a particular model.
117
124
  *
125
+ * An override's `contextSize` (when set and `> 0`) replaces detection;
126
+ * otherwise the value is autodetected from the server, falling back to
127
+ * {@link FALLBACK_CTX}. A stored `0` behaves as if the key were absent.
128
+ *
118
129
  * @returns The context size in tokens
119
130
  */
120
131
  async getContextSize(): Promise<number> {
132
+ const overridden = this.server.findOverrideForModel(this.id)?.contextSize;
133
+ if (overridden && overridden > 0) return overridden;
134
+
121
135
  try {
122
136
  const { data } = await this.server.fetchModels();
123
137
  const { n_ctx } = data.find((m) => m.id === this.id)?.meta!;
@@ -162,7 +176,18 @@ export abstract class BaseModel {
162
176
  * @returns A Pi configuration object
163
177
  */
164
178
  async toProviderConfig(): Promise<ProviderModelConfig> {
165
- const response = {
179
+ const override = this.server.findOverrideForModel(this.id) ?? {};
180
+
181
+ // Merge the matched override's cost with zero defaults
182
+ const userCost = override.cost ?? {};
183
+ const cost: ModelCost = {
184
+ input: userCost.input ?? 0,
185
+ output: userCost.output ?? 0,
186
+ cacheRead: userCost.cacheRead ?? 0,
187
+ cacheWrite: userCost.cacheWrite ?? 0,
188
+ };
189
+
190
+ const response: ProviderModelConfig = {
166
191
  id: this.id,
167
192
  name: this.name,
168
193
  reasoning: this.reasoning,
@@ -176,10 +201,15 @@ export abstract class BaseModel {
176
201
  },
177
202
  input: await this.getCapabilities(),
178
203
  contextWindow: await this.getContextSize(),
179
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
180
- maxTokens: await this.getContextSize(),
204
+ cost,
205
+ maxTokens: override.maxTokens ?? (await this.getContextSize()),
181
206
  };
182
207
 
208
+ // Add compat if the override specifies it
209
+ if (override.compat) {
210
+ response.compat = override.compat;
211
+ }
212
+
183
213
  return response;
184
214
  }
185
215
 
@@ -245,7 +275,7 @@ export abstract class BaseModel {
245
275
  interval: number = POLLING_INTERVAL,
246
276
  ): Promise<void> {
247
277
  if (timeout === undefined) {
248
- timeout = this.server.pollingTimeout;
278
+ timeout = await this.server.getPollingTimeout();
249
279
  }
250
280
  while ((await this.getStatus()) === Status.LOADING) {
251
281
  // Force a timeout if we wasted too much time polling
@@ -41,7 +41,8 @@ export class RouterModel extends BaseModel {
41
41
  }
42
42
  }
43
43
 
44
- const timeout = this.server.pollingTimeout - elapsed;
44
+ const pollingTimeout = await this.server.getPollingTimeout();
45
+ const timeout = pollingTimeout - elapsed;
45
46
  return await super.pollStatus(startTime, timeout);
46
47
  }
47
48
 
package/src/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ApiClient } from "./api/client";
2
2
  import {
3
3
  API_KEY_PLACEHOLDER,
4
+ ENDPOINT_PREFIX,
4
5
  PROVIDER_NAME,
5
6
  PROVIDER_PREFIX,
6
7
  } from "./constants";
@@ -13,12 +14,14 @@ import {
13
14
  PropsModelEndpoint,
14
15
  } from "./interfaces/endpoints/props";
15
16
  import type { ServerOptions } from "./interfaces/server";
17
+ import type { ModelOverride } from "./interfaces/settings";
16
18
  import type { LlamaSettingsManager } from "./managers/settings";
17
19
  import { BaseModel } from "./models/baseModel";
18
20
  import { LegacyModel } from "./models/legacyModel";
19
21
  import { RouterModel } from "./models/routerModel";
20
22
  import { SingleModel } from "./models/singleModel";
21
23
  import { SSEManager } from "./sse/manager";
24
+ import { checkServerHealth } from "./utils/health";
22
25
 
23
26
  /**
24
27
  * Optional constructor collaborators for {@link Server} — the seam tests use
@@ -58,20 +61,33 @@ export class Server {
58
61
  return this.options.baseUrl;
59
62
  }
60
63
 
64
+ /**
65
+ * Base URL of the OpenAI-compatible API: {@link baseUrl} joined with
66
+ * {@link ENDPOINT_PREFIX}. Idempotent — a baseUrl that already ends with
67
+ * the prefix is returned untouched, so proxied setups exposing the API
68
+ * under a /v1 path don't double up.
69
+ */
70
+ get apiBaseUrl(): string {
71
+ const { baseUrl } = this.options;
72
+ return baseUrl.endsWith(ENDPOINT_PREFIX)
73
+ ? baseUrl
74
+ : `${baseUrl}${ENDPOINT_PREFIX}`;
75
+ }
76
+
61
77
  /**
62
78
  * Maximum time (ms) for server verification and SSE support probe.
63
79
  * Resolved live from the injected settings manager.
64
80
  */
65
- get serverTimeout(): number {
66
- return this.settings.resolveTimeouts().serverTimeout;
81
+ async getServerTimeout(): Promise<number> {
82
+ return (await this.settings.resolveTimeouts()).serverTimeout;
67
83
  }
68
84
 
69
85
  /**
70
86
  * Maximum time (ms) to wait for model loading before giving up.
71
87
  * Resolved live from the injected settings manager.
72
88
  */
73
- get pollingTimeout(): number {
74
- return this.settings.resolveTimeouts().pollingTimeout;
89
+ async getPollingTimeout(): Promise<number> {
90
+ return (await this.settings.resolveTimeouts()).pollingTimeout;
75
91
  }
76
92
 
77
93
  /**
@@ -162,28 +178,16 @@ export class Server {
162
178
  /**
163
179
  * Checks if the server is ready, with a timeout.
164
180
  *
181
+ * Delegates to the shared health probe (`utils/health`) — bypasses the
182
+ * `ApiClient` on purpose: each caller probes once per scan, so the
183
+ * client's cache/dedup would be dead weight, and a plain `fetch` with
184
+ * `AbortSignal.timeout` actually cancels the request on timeout.
185
+ *
165
186
  * @param timeout Maximum time to wait for the health check
166
187
  * @returns The server status
167
188
  */
168
189
  async isReady(timeout: number): Promise<ServerStatus> {
169
- try {
170
- const timeoutPromise = new Promise<never>((_, reject) =>
171
- setTimeout(() => reject(new Error("timeout")), timeout),
172
- );
173
- const health = await Promise.race([
174
- this.fetchServerHealth(),
175
- timeoutPromise,
176
- ]);
177
- if (health.status === "ok") {
178
- return ServerStatus.READY;
179
- }
180
- return ServerStatus.UNREACHABLE;
181
- } catch (error) {
182
- if (error instanceof Error && error.message === "timeout") {
183
- return ServerStatus.TIMEOUT;
184
- }
185
- return ServerStatus.UNREACHABLE;
186
- }
190
+ return checkServerHealth(this.baseUrl, timeout, this.getApiKey());
187
191
  }
188
192
 
189
193
  /**
@@ -201,7 +205,9 @@ export class Server {
201
205
  * @return The models from the server
202
206
  */
203
207
  async fetchModels(): Promise<ModelsEndpoint> {
204
- return await this.apiClient.get<ModelsEndpoint>("/v1/models");
208
+ return await this.apiClient.get<ModelsEndpoint>(
209
+ `${ENDPOINT_PREFIX}/models`,
210
+ );
205
211
  }
206
212
 
207
213
  /**
@@ -225,6 +231,39 @@ export class Server {
225
231
  );
226
232
  }
227
233
 
234
+ /**
235
+ * Returns the per-model override configuration for this server.
236
+ */
237
+ getOverrides(): Record<string, ModelOverride> {
238
+ return this.options.overrides ?? {};
239
+ }
240
+
241
+ /**
242
+ * Resolves the override for a given model ID using prefix matching.
243
+ *
244
+ * Keys in the overrides map are treated as prefix filters — a model ID
245
+ * matches if it starts with the key. When multiple keys match, the
246
+ * longest (most specific) key wins. Empty keys are ignored.
247
+ *
248
+ * @param modelId — The model ID to look up.
249
+ * @returns The matching override, or `undefined` if no key matches.
250
+ */
251
+ findOverrideForModel(modelId: string): ModelOverride | undefined {
252
+ const overrides = this.getOverrides();
253
+ let best: ModelOverride | undefined;
254
+ let bestLen = 0;
255
+
256
+ for (const [key, override] of Object.entries(overrides)) {
257
+ if (!key) continue;
258
+ if (modelId.startsWith(key) && key.length > bestLen) {
259
+ best = override;
260
+ bestLen = key.length;
261
+ }
262
+ }
263
+
264
+ return best;
265
+ }
266
+
228
267
  /**
229
268
  * Sends a request associated to a specific model from the server
230
269
  *
@@ -33,16 +33,16 @@ export class SSEManager {
33
33
  * Maximum time (ms) for server verification and SSE support probe.
34
34
  * Delegates to the owning {@link Server}.
35
35
  */
36
- get serverTimeout(): number {
37
- return this.server.serverTimeout;
36
+ async getServerTimeout(): Promise<number> {
37
+ return this.server.getServerTimeout();
38
38
  }
39
39
 
40
40
  /**
41
41
  * Maximum time (ms) to wait for model loading before giving up.
42
42
  * Delegates to the owning {@link Server}.
43
43
  */
44
- get pollingTimeout(): number {
45
- return this.server.pollingTimeout;
44
+ async getPollingTimeout(): Promise<number> {
45
+ return this.server.getPollingTimeout();
46
46
  }
47
47
 
48
48
  /**
@@ -65,7 +65,7 @@ export class SSEManager {
65
65
  const url = buildSSEUrl(this.sseEndpoint, this.apiKey);
66
66
  const response = await fetch(url, {
67
67
  method: "GET",
68
- signal: AbortSignal.timeout(this.serverTimeout),
68
+ signal: AbortSignal.timeout(await this.getServerTimeout()),
69
69
  });
70
70
  this.sseSupported =
71
71
  response.ok &&
@@ -179,11 +179,12 @@ export class SSEManager {
179
179
  * @param modelId - The model ID to subscribe to
180
180
  * @returns Promise that resolves with the final status string
181
181
  */
182
- subscribeToStatus(modelId: string): Promise<StatusChangeData> {
183
- return new Promise((resolve, reject) => {
182
+ async subscribeToStatus(modelId: string): Promise<StatusChangeData> {
183
+ return new Promise(async (resolve, reject) => {
184
+ const pollingTimeout = await this.getPollingTimeout();
184
185
  const timeout = setTimeout(
185
186
  () => reject(new Error(`SSE status timeout for model: ${modelId}`)),
186
- this.pollingTimeout,
187
+ pollingTimeout,
187
188
  );
188
189
 
189
190
  this.subscribeToSSE(modelId, (event: SSEEvent) => {
@@ -0,0 +1,290 @@
1
+ import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ Component,
4
+ Keybinding,
5
+ SelectListTheme,
6
+ TUI,
7
+ } from "@earendil-works/pi-tui";
8
+ import {
9
+ Container,
10
+ getKeybindings,
11
+ Input,
12
+ SelectList,
13
+ Spacer,
14
+ Text,
15
+ } from "@earendil-works/pi-tui";
16
+
17
+ /**
18
+ * Base dialog class encapsulating shared UI utilities (frame, hints,
19
+ * themes) so concrete dialogs don't duplicate code.
20
+ */
21
+ abstract class BaseDialog {
22
+ protected theme: Theme;
23
+ protected tui: TUI;
24
+
25
+ constructor(theme: Theme, tui: TUI) {
26
+ this.theme = theme;
27
+ this.tui = tui;
28
+ }
29
+
30
+ // -- shared utilities ------------------------------------------------------
31
+
32
+ protected selectListTheme(): SelectListTheme {
33
+ return {
34
+ selectedPrefix: (text) => this.theme.fg("accent", text),
35
+ selectedText: (text) => this.theme.fg("accent", text),
36
+ description: (text) => this.theme.fg("muted", text),
37
+ scrollInfo: (text) => this.theme.fg("dim", text),
38
+ noMatch: (text) => this.theme.fg("warning", text),
39
+ };
40
+ }
41
+
42
+ protected hint(action: Keybinding, description: string): string {
43
+ const keys = getKeybindings().getKeys(action).join("/");
44
+ return (
45
+ this.theme.fg("dim", keys) + this.theme.fg("muted", ` ${description}`)
46
+ );
47
+ }
48
+
49
+ protected inputFooter(): string {
50
+ return `${this.hint("tui.select.confirm", "save")} • ${this.hint("tui.select.cancel", "cancel")}`;
51
+ }
52
+
53
+ protected selectFooter(): string {
54
+ return `${this.hint("tui.select.confirm", "select")} • ${this.hint("tui.select.cancel", "cancel")}`;
55
+ }
56
+
57
+ protected createFrame(
58
+ title: string,
59
+ body: Component[],
60
+ footer?: string,
61
+ ): Container {
62
+ const container = new Container();
63
+ container.addChild(
64
+ new DynamicBorder((text) => this.theme.fg("accent", text)),
65
+ );
66
+ container.addChild(
67
+ new Text(this.theme.fg("accent", this.theme.bold(title)), 1, 0),
68
+ );
69
+ for (const child of body) container.addChild(child);
70
+ if (footer) {
71
+ container.addChild(new Spacer(1));
72
+ container.addChild(new Text(this.theme.fg("dim", footer), 1, 0));
73
+ }
74
+ container.addChild(
75
+ new DynamicBorder((text) => this.theme.fg("accent", text)),
76
+ );
77
+ return container;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Shared `SettingItem.submenu` factory for the text fields of both editors.
83
+ * Opens an `InputDialog` pre-filled with `initialValue`; Enter commits the
84
+ * validated value via `done(value)`, Esc cancels via `done(undefined)`.
85
+ */
86
+ export class InputDialog extends BaseDialog {
87
+ private readonly options: InputDialogOptions;
88
+ private readonly body = new Container();
89
+ private readonly container: Container;
90
+ private readonly input = new Input();
91
+ private errorText: Text | undefined;
92
+
93
+ constructor(options: InputDialogOptions) {
94
+ super(options.theme, options.tui);
95
+ this.options = options;
96
+
97
+ const initial = options.initialValue ?? "";
98
+ this.input.setValue(initial);
99
+ for (let i = 0; i < [...initial].length; i++) {
100
+ this.input.handleInput("\x1b[C");
101
+ }
102
+ this.input.onSubmit = (value) => this.submit(value);
103
+ this.input.onEscape = () => options.onCancel();
104
+
105
+ this.body.addChild(new Text(this.theme.fg("text", options.message), 1, 0));
106
+ if (options.placeholder) {
107
+ this.body.addChild(
108
+ new Text(this.theme.fg("dim", `e.g., ${options.placeholder}`), 1, 0),
109
+ );
110
+ }
111
+ this.body.addChild(this.input);
112
+
113
+ this.container = this.createFrame(
114
+ options.title,
115
+ [this.body],
116
+ this.inputFooter(),
117
+ );
118
+ }
119
+
120
+ // -- Component -------------------------------------------------------------
121
+
122
+ invalidate(): void {
123
+ this.container.invalidate();
124
+ }
125
+
126
+ handleInput(data: string): void {
127
+ this.input.handleInput(data);
128
+ this.tui.requestRender();
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ return this.container.render(width);
133
+ }
134
+
135
+ // -- Focusable ---------------------------------------------------------------
136
+
137
+ get focused(): boolean {
138
+ return this.input.focused;
139
+ }
140
+
141
+ set focused(value: boolean) {
142
+ this.input.focused = value;
143
+ }
144
+
145
+ // -- helpers -------------------------------------------------------------------
146
+
147
+ private submit(raw: string): void {
148
+ const validated = this.options.validate ? this.options.validate(raw) : raw;
149
+ if (validated === null) {
150
+ this.setError(`Invalid value "${raw}"`);
151
+ return;
152
+ }
153
+ this.options.onSubmit(validated);
154
+ }
155
+
156
+ private setError(message: string | undefined): void {
157
+ if (this.errorText) {
158
+ this.body.removeChild(this.errorText);
159
+ this.errorText = undefined;
160
+ }
161
+ if (message) {
162
+ this.errorText = new Text(this.theme.fg("error", message), 1, 0);
163
+ this.body.addChild(this.errorText);
164
+ }
165
+ this.tui.requestRender();
166
+ }
167
+
168
+ /**
169
+ * Builds a `SettingItem.submenu` factory that opens an `InputDialog`.
170
+ * The dialog prefills from the `currentValue` the containing
171
+ * `SettingsList` passes at activation time — not a value captured at
172
+ * build time — so re-entering a field after a commit shows the
173
+ * just-saved value.
174
+ */
175
+ static inputSubmenu =
176
+ (
177
+ theme: Theme,
178
+ tui: TUI,
179
+ title: string,
180
+ message: string,
181
+ placeholder: string | undefined,
182
+ validate?: (raw: string) => string | null,
183
+ ) =>
184
+ (
185
+ currentValue: string,
186
+ done: (selectedValue?: string) => void,
187
+ ): Component => {
188
+ const dialog = new InputDialog({
189
+ theme,
190
+ tui,
191
+ title,
192
+ message,
193
+ placeholder,
194
+ initialValue: currentValue,
195
+ validate,
196
+ onSubmit: (value) => done(value),
197
+ onCancel: () => done(undefined),
198
+ });
199
+ dialog.focused = true;
200
+ return dialog;
201
+ };
202
+ }
203
+
204
+ export interface InputDialogOptions {
205
+ theme: Theme;
206
+ tui: TUI;
207
+ title: string;
208
+ message: string;
209
+ placeholder?: string;
210
+ initialValue?: string;
211
+ validate?: (raw: string) => string | null;
212
+ onSubmit: (value: string) => void;
213
+ onCancel: () => void;
214
+ }
215
+
216
+ /**
217
+ * A framed confirmation dialog with a two-option `SelectList`
218
+ * (Delete / Cancel), matching the confirm pattern of pi's `/llama` view.
219
+ */
220
+ export class ConfirmDialog extends BaseDialog {
221
+ private readonly options: ConfirmDialogOptions;
222
+ private readonly container: Container;
223
+ private readonly list: SelectList;
224
+ private isFocused = false;
225
+
226
+ constructor(options: ConfirmDialogOptions) {
227
+ super(options.theme, options.tui);
228
+ this.options = options;
229
+
230
+ this.list = new SelectList(
231
+ [
232
+ { value: "confirm", label: options.confirmLabel ?? "Delete" },
233
+ { value: "cancel", label: options.cancelLabel ?? "Cancel" },
234
+ ],
235
+ 2,
236
+ this.selectListTheme(),
237
+ );
238
+ this.list.onSelect = (item) => {
239
+ if (item.value === "confirm") options.onConfirm();
240
+ else options.onCancel();
241
+ };
242
+ this.list.onCancel = () => options.onCancel();
243
+
244
+ this.container = this.createFrame(
245
+ options.title,
246
+ [
247
+ new Text(this.theme.fg("text", options.message), 1, 0),
248
+ new Spacer(1),
249
+ this.list,
250
+ ],
251
+ this.selectFooter(),
252
+ );
253
+ }
254
+
255
+ // -- Component -------------------------------------------------------------
256
+
257
+ invalidate(): void {
258
+ this.container.invalidate();
259
+ }
260
+
261
+ handleInput(data: string): void {
262
+ this.list.handleInput(data);
263
+ this.tui.requestRender();
264
+ }
265
+
266
+ render(width: number): string[] {
267
+ return this.container.render(width);
268
+ }
269
+
270
+ // -- Focusable ---------------------------------------------------------------
271
+
272
+ get focused(): boolean {
273
+ return this.isFocused;
274
+ }
275
+
276
+ set focused(value: boolean) {
277
+ this.isFocused = value;
278
+ }
279
+ }
280
+
281
+ export interface ConfirmDialogOptions {
282
+ theme: Theme;
283
+ tui: TUI;
284
+ title: string;
285
+ message: string;
286
+ confirmLabel?: string;
287
+ cancelLabel?: string;
288
+ onConfirm: () => void;
289
+ onCancel: () => void;
290
+ }
@@ -0,0 +1,119 @@
1
+ import type { ModelCost, ModelCostRates } from "@earendil-works/pi-ai";
2
+ import type { LlamaServer, ModelOverride } from "../interfaces/settings";
3
+
4
+ /**
5
+ * Parses a raw cost-field value. Empty input means zero (treated as unset
6
+ * — the key is removed from the settings). Everything else must be a
7
+ * finite, non-negative number.
8
+ *
9
+ * @returns The parsed value, or `null` when the input is invalid
10
+ */
11
+ export const parseCostValue = (raw: string): number | null => {
12
+ const v = raw.trim();
13
+ if (v.length === 0) return 0;
14
+ const n = Number(v);
15
+ if (isNaN(n) || !isFinite(n) || n < 0) return null;
16
+ return n;
17
+ };
18
+
19
+ /**
20
+ * Returns a copy of `cost` with `field` set to `value`. A `0` (or
21
+ * non-finite) value removes the field — unset cost fields default to
22
+ * zero at read time — and when no fields remain, returns `undefined` so
23
+ * the whole `cost` object can be dropped. Immutable.
24
+ */
25
+ export const applyCostFieldValue = (
26
+ cost: ModelOverride["cost"],
27
+ field: keyof ModelCostRates,
28
+ value: number,
29
+ ): Partial<ModelCost> | undefined => {
30
+ const next = { ...cost };
31
+ if (isFinite(value) && value > 0) next[field] = value;
32
+ else delete next[field];
33
+ return Object.keys(next).length > 0 ? next : undefined;
34
+ };
35
+
36
+ /**
37
+ * Returns a new list with the server at `serverIndex` updated so its
38
+ * `overrides` map gains a new `pattern → override` entry. Immutable.
39
+ */
40
+ export const addOverrideEntry = (
41
+ servers: LlamaServer[],
42
+ serverIndex: number,
43
+ pattern: string,
44
+ override: ModelOverride = {},
45
+ ): LlamaServer[] =>
46
+ servers.map((server, i) => {
47
+ if (i !== serverIndex) return server;
48
+ const overrides = { ...server.overrides, [pattern]: override };
49
+ return { ...server, overrides };
50
+ });
51
+
52
+ /**
53
+ * Returns a new list with the override entry at `entryIndex` (within the
54
+ * server's overrides map) replaced by `pattern → override` in a single
55
+ * mutation — used both for renaming the pattern and for editing its
56
+ * fields. The entry keeps its position in the map's iteration order.
57
+ * Immutable.
58
+ */
59
+ export const updateOverrideEntry = (
60
+ servers: LlamaServer[],
61
+ serverIndex: number,
62
+ entryIndex: number,
63
+ pattern: string,
64
+ override: ModelOverride,
65
+ ): LlamaServer[] =>
66
+ servers.map((server, i) => {
67
+ if (i !== serverIndex) return server;
68
+ const overrides = server.overrides ?? {};
69
+ const entries = Object.entries(overrides).map(([k, v], j) =>
70
+ j === entryIndex ? [pattern, override] : [k, v],
71
+ );
72
+ return { ...server, overrides: Object.fromEntries(entries) };
73
+ });
74
+
75
+ /**
76
+ * Returns a new list with the override entry at `entryIndex` (within the
77
+ * server's overrides map) removed. Immutable.
78
+ */
79
+ export const removeOverrideEntry = (
80
+ servers: LlamaServer[],
81
+ serverIndex: number,
82
+ entryIndex: number,
83
+ ): LlamaServer[] =>
84
+ servers.map((server, i) => {
85
+ if (i !== serverIndex) return server;
86
+ const overrides = server.overrides ?? {};
87
+ const entries = Object.entries(overrides).filter(
88
+ (_, j) => j !== entryIndex,
89
+ );
90
+ return { ...server, overrides: Object.fromEntries(entries) };
91
+ });
92
+
93
+ /**
94
+ * Formats an override as a summary shown in the entry rows:
95
+ * `input: $0.2, output: $0.6, cache read: $0.01, capabilities: text,image,
96
+ * reasoning: false` — cost fields that are zero or undefined are omitted
97
+ * for brevity; `—` when everything is empty.
98
+ */
99
+ export const formatOverrideSummary = (override: ModelOverride): string => {
100
+ const parts: string[] = [];
101
+ const cost = override.cost ?? {};
102
+ if (cost.input) parts.push(`input: $${cost.input}`);
103
+ if (cost.output) parts.push(`output: $${cost.output}`);
104
+ if (cost.cacheRead) parts.push(`cache read: $${cost.cacheRead}`);
105
+ if (cost.cacheWrite) parts.push(`cache write: $${cost.cacheWrite}`);
106
+ if (override.capabilities?.length) {
107
+ parts.push(`capabilities: ${override.capabilities.join(",")}`);
108
+ }
109
+ if (override.reasoning !== undefined) {
110
+ parts.push(`reasoning: ${override.reasoning}`);
111
+ }
112
+ if (override.contextSize !== undefined) {
113
+ parts.push(`contextSize: ${override.contextSize}`);
114
+ }
115
+ if (override.maxTokens !== undefined) {
116
+ parts.push(`maxTokens: ${override.maxTokens}`);
117
+ }
118
+ return parts.length > 0 ? parts.join(", ") : "—";
119
+ };