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.
- package/README.md +186 -58
- package/package.json +5 -5
- package/src/constants.ts +8 -0
- package/src/interfaces/server.ts +7 -0
- package/src/interfaces/settings.ts +67 -1
- package/src/managers/command.ts +110 -31
- package/src/managers/events.ts +2 -2
- package/src/managers/server.ts +14 -11
- package/src/managers/settings.ts +120 -45
- package/src/models/baseModel.ts +37 -7
- package/src/models/routerModel.ts +2 -1
- package/src/server.ts +62 -23
- package/src/sse/manager.ts +9 -8
- package/src/ui/dialog.ts +290 -0
- package/src/ui/overrideEntryEditor.ts +119 -0
- package/src/ui/overrideSettingsList.ts +710 -0
- package/src/ui/serverListEditor.ts +19 -441
- package/src/ui/serverSettingsList.ts +513 -0
- package/src/ui/strings.ts +127 -0
- package/src/utils/health.ts +48 -0
- package/src/utils/settingsStore.ts +2 -6
- package/tests/commandManager.test.ts +132 -16
- package/tests/dialog.test.ts +228 -0
- package/tests/events.test.ts +12 -9
- package/tests/health.test.ts +116 -0
- package/tests/legacyModel.test.ts +4 -19
- package/tests/mocks.ts +12 -8
- package/tests/overrides.test.ts +384 -0
- package/tests/server.test.ts +59 -16
- package/tests/serverManager.test.ts +28 -62
- package/tests/settings.test.ts +489 -43
- package/tests/settingsStore.test.ts +0 -19
- package/tests/singleModel.test.ts +32 -0
- package/tests/sseManager.test.ts +4 -4
- package/tests/serverListEditor.test.ts +0 -637
package/src/models/baseModel.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>(
|
|
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
|
*
|
package/src/sse/manager.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
return this.server.
|
|
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
|
-
|
|
45
|
-
return this.server.
|
|
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.
|
|
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
|
-
|
|
187
|
+
pollingTimeout,
|
|
187
188
|
);
|
|
188
189
|
|
|
189
190
|
this.subscribeToSSE(modelId, (event: SSEEvent) => {
|
package/src/ui/dialog.ts
ADDED
|
@@ -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
|
+
};
|