pi-llama-cpp 0.9.2 → 0.10.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,5 +1,5 @@
1
1
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
- import { DEFAULT_CTX, POLLING_INTERVAL, POLLING_TIMEOUT } from "../constants";
2
+ import { FALLBACK_CTX, POLLING_INTERVAL } from "../constants";
3
3
  import { Mode } from "../enums/mode";
4
4
  import { Status } from "../enums/status";
5
5
  import { DataProperty } from "../interfaces/endpoints/models";
@@ -130,9 +130,9 @@ export abstract class BaseModel {
130
130
  const { data } = await this.server.fetchModels();
131
131
  const { n_ctx } = data.find((m) => m.id === this.id)?.meta!;
132
132
 
133
- return n_ctx ?? DEFAULT_CTX;
133
+ return n_ctx ?? FALLBACK_CTX;
134
134
  } catch {
135
- return DEFAULT_CTX;
135
+ return FALLBACK_CTX;
136
136
  }
137
137
  }
138
138
 
@@ -202,11 +202,31 @@ export abstract class BaseModel {
202
202
  await this.server.postRequest("load", this.id);
203
203
 
204
204
  if (await this.server.sseManager.probeSSE()) {
205
- const { status, exit_code } =
206
- await this.server.sseManager.subscribeToStatus(this.id);
205
+ try {
206
+ const { status, exit_code } =
207
+ await this.server.sseManager.subscribeToStatus(this.id);
207
208
 
208
- if (status === "failed" || (status === "unloaded" && exit_code !== 0)) {
209
- throw new Error(`Model loading failed: ${this.id}`);
209
+ if (status === "failed" || (status === "unloaded" && exit_code !== 0)) {
210
+ throw new Error(`Model loading failed: ${this.id}`);
211
+ }
212
+ } catch (err) {
213
+ // A real failed status from the server stays fatal.
214
+ if (
215
+ err instanceof Error &&
216
+ err.message.startsWith("Model loading failed")
217
+ ) {
218
+ throw err;
219
+ }
220
+ // An SSE timeout or connection failure is not proof the load failed:
221
+ // subscribeToStatus timers are only cleared by a terminal event on
222
+ // the same SSE connection, so a timer orphaned by a server restart
223
+ // or reconnect can fire during a later, healthy load. Check the
224
+ // model's real status over HTTP before surfacing an error.
225
+ await this.pollStatus();
226
+ const finalStatus = await this.getStatus();
227
+ if (finalStatus !== Status.LOADED && finalStatus !== Status.SLEEPING) {
228
+ throw new Error(`Model loading failed: ${this.id}`);
229
+ }
210
230
  }
211
231
  } else {
212
232
  await this.pollStatus();
@@ -224,14 +244,17 @@ export abstract class BaseModel {
224
244
  * Polls llama-server to check when the model is loaded
225
245
  *
226
246
  * @param startTime The initial polling timestamp
227
- * @param timeout The maximum amount of ms before timeout. Defaults to POLLING_TIMEOUT
247
+ * @param timeout The maximum amount of ms before timeout. Defaults to server's pollingTimeout
228
248
  * @param interval The polling interval. Defaults to POLLING_INTERVAL
229
249
  */
230
250
  async pollStatus(
231
251
  startTime: number = Date.now(),
232
- timeout: number = POLLING_TIMEOUT,
252
+ timeout?: number,
233
253
  interval: number = POLLING_INTERVAL,
234
254
  ): Promise<void> {
255
+ if (timeout === undefined) {
256
+ timeout = this.server.pollingTimeout;
257
+ }
235
258
  while ((await this.getStatus()) === Status.LOADING) {
236
259
  // Force a timeout if we wasted too much time polling
237
260
  if (Date.now() - startTime > timeout) {
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CTX } from "../constants";
1
+ import { FALLBACK_CTX } from "../constants";
2
2
  import { Mode } from "../enums/mode";
3
3
  import { SingleModel } from "./singleModel";
4
4
 
@@ -25,7 +25,7 @@ export class LegacyModel extends SingleModel {
25
25
  const [{ max_model_len }] = data;
26
26
  const contextSize = max_model_len === 0 ? n_ctx : max_model_len;
27
27
 
28
- return contextSize ?? DEFAULT_CTX;
28
+ return contextSize ?? FALLBACK_CTX;
29
29
  }
30
30
 
31
31
  /**
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CTX, POLLING_INTERVAL, POLLING_TIMEOUT } from "../constants";
1
+ import { FALLBACK_CTX, POLLING_INTERVAL } from "../constants";
2
2
  import { Mode } from "../enums/mode";
3
3
  import { Status } from "../enums/status";
4
4
  import { BaseModel } from "./baseModel";
@@ -41,7 +41,7 @@ export class RouterModel extends BaseModel {
41
41
  }
42
42
  }
43
43
 
44
- const timeout = POLLING_TIMEOUT - elapsed;
44
+ const timeout = this.server.pollingTimeout - elapsed;
45
45
  return await super.pollStatus(startTime, timeout);
46
46
  }
47
47
 
@@ -60,7 +60,7 @@ export class RouterModel extends BaseModel {
60
60
  const response =
61
61
  this.extractFrom("--ctx-size") ??
62
62
  this.extractFrom("--fit-ctx") ??
63
- DEFAULT_CTX;
63
+ FALLBACK_CTX;
64
64
 
65
65
  return response;
66
66
  }
package/src/server.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { ApiClient } from "./api/client";
2
- import { PROVIDER_NAME, PROVIDER_PREFIX } from "./constants";
2
+ import {
3
+ API_KEY_PLACEHOLDER,
4
+ POLLING_TIMEOUT,
5
+ PROVIDER_NAME,
6
+ PROVIDER_PREFIX,
7
+ SERVER_TIMEOUT,
8
+ } from "./constants";
3
9
  import { Mode } from "./enums/mode";
4
10
  import { ServerStatus } from "./enums/serverStatus";
5
11
  import { HealthEndpoint } from "./interfaces/endpoints/health";
@@ -8,20 +14,25 @@ import {
8
14
  PropsEndpoint,
9
15
  PropsModelEndpoint,
10
16
  } from "./interfaces/endpoints/props";
17
+ import { settings } from "./managers/settings";
11
18
  import { BaseModel } from "./models/baseModel";
12
19
  import { LegacyModel } from "./models/legacyModel";
13
20
  import { RouterModel } from "./models/routerModel";
14
21
  import { SingleModel } from "./models/singleModel";
15
- import { ConfigResolver } from "./resolver";
16
22
  import { SSEManager } from "./sse/manager";
17
23
 
18
24
  export class Server {
19
25
  public readonly models: BaseModel[] = [];
20
- private configResolver = new ConfigResolver();
21
26
  private apiClient!: ApiClient;
22
27
  private sse!: SSEManager;
23
28
 
24
- constructor(readonly baseUrl: string) {}
29
+ constructor(
30
+ readonly baseUrl: string,
31
+ private readonly customId?: string,
32
+ private readonly customName?: string,
33
+ readonly serverTimeout: number = SERVER_TIMEOUT,
34
+ readonly pollingTimeout: number = POLLING_TIMEOUT,
35
+ ) {}
25
36
 
26
37
  /**
27
38
  * Provides access to the SSE manager for direct subscriptions.
@@ -32,24 +43,37 @@ export class Server {
32
43
 
33
44
  /**
34
45
  * Generates a unique provider ID from a server URL.
46
+ * Uses custom ID if provided, otherwise falls back to URL-based ID.
35
47
  */
36
48
  get providerId(): string {
37
- return `${PROVIDER_PREFIX}=${this.baseUrl}`;
49
+ return this.customId ?? `${PROVIDER_PREFIX}=${this.baseUrl}`;
38
50
  }
39
51
 
40
52
  /**
41
53
  * Generates a human-readable provider name from a server URL.
54
+ * Uses custom name as suffix if provided.
42
55
  */
43
56
  get providerName(): string {
57
+ if (this.customName) {
58
+ return `${PROVIDER_NAME} (${this.customName})`;
59
+ }
44
60
  return `${PROVIDER_NAME} (${this.baseUrl})`;
45
61
  }
46
62
 
47
63
  /**
48
- * Retrieves the API key from the resolver
64
+ * Retrieves the API key from the config resolver.
65
+ * Tries custom ID first, then falls back to URL-based ID.
66
+ *
49
67
  * @returns The API key
50
68
  */
51
- async getApiKey(): Promise<string> {
52
- return await this.configResolver.resolveApiKey(this.providerId);
69
+ getApiKey(): string {
70
+ // Try custom ID first
71
+ if (this.customId) {
72
+ const key = settings.resolveApiKey(this.customId);
73
+ if (key !== API_KEY_PLACEHOLDER) return key;
74
+ }
75
+ // Fall back to URL-based ID
76
+ return settings.resolveApiKey(`${PROVIDER_PREFIX}=${this.baseUrl}`);
53
77
  }
54
78
 
55
79
  /**
@@ -59,7 +83,7 @@ export class Server {
59
83
  async initialize() {
60
84
  const apiKey = await this.getApiKey();
61
85
  this.apiClient = new ApiClient(this.baseUrl, apiKey);
62
- this.sse = new SSEManager(this.baseUrl, apiKey);
86
+ this.sse = new SSEManager(this.baseUrl, apiKey, this.serverTimeout);
63
87
  const { data } = await this.fetchModels();
64
88
  const mode = await this.detectServerMode();
65
89
 
@@ -27,6 +27,7 @@ export class SSEManager {
27
27
  constructor(
28
28
  private readonly baseUrl: string,
29
29
  private readonly apiKey: string,
30
+ readonly serverTimeout: number = SERVER_TIMEOUT,
30
31
  ) {}
31
32
 
32
33
  /**
@@ -52,7 +53,7 @@ export class SSEManager {
52
53
  }
53
54
  const response = await fetch(url, {
54
55
  method: "GET",
55
- signal: AbortSignal.timeout(SERVER_TIMEOUT),
56
+ signal: AbortSignal.timeout(this.serverTimeout),
56
57
  });
57
58
  this.sseSupported =
58
59
  response.ok &&
@@ -1,7 +1,25 @@
1
1
  import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { DEFAULT_THINKING_BUDGETS } from "../src/constants";
2
+ import { THINKING_BUDGETS } from "../src/constants";
3
+ import { Status } from "../src/enums/status";
3
4
  import { createMockModel, createMockServer } from "./mocks";
4
5
 
6
+ // Mock settings — reactToModelSelect and autoloadOnMessage
7
+ const mockSettings = {
8
+ resolveReactToModelSelect: vi.fn(() => true),
9
+ resolveAutoloadOnMessage: vi.fn(() => false),
10
+ resolveThinkingLevel: vi.fn(() => "medium"),
11
+ resolveThinkingBudgets: vi.fn(() => ({ ...THINKING_BUDGETS })),
12
+ };
13
+
14
+ // Wire resolveThinkingBudgets to use the SettingsManager mock when set
15
+ mockSettings.resolveThinkingBudgets.mockImplementation(() => {
16
+ const userBudgets = mockSettingsManager.getThinkingBudgets();
17
+ if (userBudgets) {
18
+ return { ...THINKING_BUDGETS, ...userBudgets };
19
+ }
20
+ return { ...THINKING_BUDGETS };
21
+ });
22
+
5
23
  // Create a mutable mock object shared across tests
6
24
  const mockSettingsManager = {
7
25
  getDefaultThinkingLevel: vi.fn(() => "medium"),
@@ -19,6 +37,10 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
19
37
  };
20
38
  });
21
39
 
40
+ vi.mock("../src/managers/settings", () => ({
41
+ settings: mockSettings,
42
+ }));
43
+
22
44
  let EventManager: typeof import("../src/managers/events").EventManager;
23
45
 
24
46
  beforeAll(async () => {
@@ -117,7 +139,7 @@ describe("EventManager.onBeforeProviderRequest", () => {
117
139
 
118
140
  expect(result.messages).toEqual([{ role: "user", content: "test" }]);
119
141
  expect(result.temperature).toBe(0.7);
120
- expect(result.thinking_budget_tokens).toBe(DEFAULT_THINKING_BUDGETS.low);
142
+ expect(result.thinking_budget_tokens).toBe(THINKING_BUDGETS.low);
121
143
  });
122
144
  });
123
145
 
@@ -194,9 +216,7 @@ describe("EventManager.onBeforeProviderRequest", () => {
194
216
  )) as Record<string, unknown>;
195
217
 
196
218
  // medium uses default since user only overrode low
197
- expect(result.thinking_budget_tokens).toBe(
198
- DEFAULT_THINKING_BUDGETS.medium,
199
- );
219
+ expect(result.thinking_budget_tokens).toBe(THINKING_BUDGETS.medium);
200
220
  });
201
221
  });
202
222
 
@@ -223,9 +243,7 @@ describe("EventManager.onBeforeProviderRequest", () => {
223
243
  )) as Record<string, unknown>;
224
244
 
225
245
  // Should fall back to default since "medium" is not in user budgets
226
- expect(result.thinking_budget_tokens).toBe(
227
- DEFAULT_THINKING_BUDGETS.medium,
228
- );
246
+ expect(result.thinking_budget_tokens).toBe(THINKING_BUDGETS.medium);
229
247
  });
230
248
 
231
249
  it("should not allow overriding 'off' — thinking stays disabled", async () => {
@@ -290,7 +308,125 @@ describe("EventManager.onBeforeProviderRequest", () => {
290
308
  ctx,
291
309
  )) as Record<string, unknown>;
292
310
 
293
- expect(result.thinking_budget_tokens).toBe(DEFAULT_THINKING_BUDGETS.high);
311
+ expect(result.thinking_budget_tokens).toBe(THINKING_BUDGETS.high);
312
+ });
313
+ });
314
+ });
315
+
316
+ describe("EventManager.onModelSelect", () => {
317
+ beforeEach(() => {
318
+ mockSettings.resolveReactToModelSelect.mockReturnValue(true);
319
+ });
320
+
321
+ it("should load the model when reactToModelSelect is true", async () => {
322
+ const server = createMockServer({
323
+ models: ["model-a"].map((id) => createMockModel(id)),
324
+ });
325
+ const eventManager = new EventManager([server]);
326
+ const ctx = createMockCtx();
327
+
328
+ const event = {
329
+ model: { provider: server.providerId, id: "model-a" },
330
+ } as any;
331
+
332
+ await eventManager.onModelSelect(event, ctx);
333
+
334
+ expect(ctx.ui.notify).toHaveBeenCalledWith("Loading model-a...", "info");
335
+ });
336
+
337
+ it("should return early when reactToModelSelect is false", async () => {
338
+ mockSettings.resolveReactToModelSelect.mockReturnValue(false);
339
+
340
+ const server = createMockServer({
341
+ models: ["model-a"].map((id) => createMockModel(id)),
294
342
  });
343
+ const eventManager = new EventManager([server]);
344
+ const ctx = createMockCtx();
345
+
346
+ const event = {
347
+ model: { provider: server.providerId, id: "model-a" },
348
+ } as any;
349
+
350
+ await eventManager.onModelSelect(event, ctx);
351
+
352
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
353
+ });
354
+ });
355
+
356
+ describe("EventManager.autoLoadIfNeeded", () => {
357
+ it("should load the model when autoloadOnMessage is true and model is UNLOADED", async () => {
358
+ mockSettings.resolveAutoloadOnMessage.mockReturnValue(true);
359
+
360
+ const server = createMockServer({
361
+ models: [
362
+ createMockModel("model-a", {
363
+ getStatus: vi.fn().mockResolvedValue(Status.UNLOADED),
364
+ load: vi.fn().mockResolvedValue(undefined),
365
+ }),
366
+ ],
367
+ });
368
+ const eventManager = new EventManager([server]);
369
+ const model = server.models[0];
370
+
371
+ await (eventManager as any).autoLoadIfNeeded(model);
372
+
373
+ expect(model.load).toHaveBeenCalled();
374
+ });
375
+
376
+ it("should not load the model when autoloadOnMessage is false", async () => {
377
+ mockSettings.resolveAutoloadOnMessage.mockReturnValue(false);
378
+
379
+ const server = createMockServer({
380
+ models: [
381
+ createMockModel("model-a", {
382
+ getStatus: vi.fn().mockResolvedValue(Status.UNLOADED),
383
+ load: vi.fn().mockResolvedValue(undefined),
384
+ }),
385
+ ],
386
+ });
387
+ const eventManager = new EventManager([server]);
388
+ const model = server.models[0];
389
+
390
+ await (eventManager as any).autoLoadIfNeeded(model);
391
+
392
+ expect(model.load).not.toHaveBeenCalled();
393
+ });
394
+
395
+ it("should not load the model when model is already LOADED", async () => {
396
+ mockSettings.resolveAutoloadOnMessage.mockReturnValue(true);
397
+
398
+ const server = createMockServer({
399
+ models: [
400
+ createMockModel("model-a", {
401
+ getStatus: vi.fn().mockResolvedValue(Status.LOADED),
402
+ load: vi.fn().mockResolvedValue(undefined),
403
+ }),
404
+ ],
405
+ });
406
+ const eventManager = new EventManager([server]);
407
+ const model = server.models[0];
408
+
409
+ await (eventManager as any).autoLoadIfNeeded(model);
410
+
411
+ expect(model.load).not.toHaveBeenCalled();
412
+ });
413
+
414
+ it("should not load the model when model is SLEEPING", async () => {
415
+ mockSettings.resolveAutoloadOnMessage.mockReturnValue(true);
416
+
417
+ const server = createMockServer({
418
+ models: [
419
+ createMockModel("model-a", {
420
+ getStatus: vi.fn().mockResolvedValue(Status.SLEEPING),
421
+ load: vi.fn().mockResolvedValue(undefined),
422
+ }),
423
+ ],
424
+ });
425
+ const eventManager = new EventManager([server]);
426
+ const model = server.models[0];
427
+
428
+ await (eventManager as any).autoLoadIfNeeded(model);
429
+
430
+ expect(model.load).not.toHaveBeenCalled();
295
431
  });
296
432
  });
package/tests/mocks.ts CHANGED
@@ -17,7 +17,7 @@ export const createMockServer = (
17
17
  const server: Partial<Server> = {
18
18
  baseUrl: "http://127.0.0.1:8080",
19
19
  models,
20
- getApiKey: () => Promise.resolve(overrides.apiKey ?? ""),
20
+ getApiKey: () => overrides.apiKey ?? "",
21
21
  fetchModels: () => mockRpc("/v1/models"),
22
22
  fetchModelProps: (modelId: string) =>
23
23
  mockRpc(`/props?model=${modelId}&autoload=false`),
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
+ import { POLLING_TIMEOUT, SERVER_TIMEOUT } from "../src/constants";
2
3
  import { ServerStatus } from "../src/enums/serverStatus";
3
4
  import { Server } from "../src/server";
4
5
  import { createMockServer, mockRpc } from "./mocks";
@@ -146,6 +147,51 @@ describe("Server postRequest", () => {
146
147
  });
147
148
  });
148
149
 
150
+ describe("Server timeouts", () => {
151
+ it("should use default timeouts when not provided", () => {
152
+ const server = new Server("http://127.0.0.1:8080");
153
+
154
+ expect(server.serverTimeout).toBe(SERVER_TIMEOUT);
155
+ expect(server.pollingTimeout).toBe(POLLING_TIMEOUT);
156
+ });
157
+
158
+ it("should accept custom serverTimeout", () => {
159
+ const server = new Server(
160
+ "http://127.0.0.1:8080",
161
+ undefined,
162
+ undefined,
163
+ 3000,
164
+ );
165
+
166
+ expect(server.serverTimeout).toBe(3000);
167
+ });
168
+
169
+ it("should accept custom pollingTimeout", () => {
170
+ const server = new Server(
171
+ "http://127.0.0.1:8080",
172
+ undefined,
173
+ undefined,
174
+ 1000,
175
+ 120000,
176
+ );
177
+
178
+ expect(server.pollingTimeout).toBe(120000);
179
+ });
180
+
181
+ it("should accept both custom timeouts", () => {
182
+ const server = new Server(
183
+ "http://127.0.0.1:8080",
184
+ "my-id",
185
+ "My Server",
186
+ 2000,
187
+ 90000,
188
+ );
189
+
190
+ expect(server.serverTimeout).toBe(2000);
191
+ expect(server.pollingTimeout).toBe(90000);
192
+ });
193
+ });
194
+
149
195
  describe("Server isReady", () => {
150
196
  it("should return READY when health status is ok", async () => {
151
197
  mockRpc.mockResolvedValueOnce({ status: "ok" });