pi-llama-cpp 0.5.0 → 0.6.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.
@@ -0,0 +1,175 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { Server } from "../src/server";
3
+ import { createMockServer, mockRpc } from "./mocks";
4
+
5
+ beforeEach(() => {
6
+ mockRpc.mockClear();
7
+ });
8
+
9
+ describe("Server providerId", () => {
10
+ it("should generate a unique provider ID from baseUrl", () => {
11
+ const server = new Server("http://127.0.0.1:8080");
12
+ expect(server.providerId).toBe("llama-server=http://127.0.0.1:8080");
13
+ });
14
+
15
+ it("should generate different IDs for different baseUrls", () => {
16
+ const server1 = new Server("http://127.0.0.1:8080");
17
+ const server2 = new Server("http://127.0.0.1:8081");
18
+ expect(server1.providerId).not.toBe(server2.providerId);
19
+ });
20
+ });
21
+
22
+ describe("Server providerName", () => {
23
+ it("should generate a human-readable provider name", () => {
24
+ const server = new Server("http://127.0.0.1:8080");
25
+ expect(server.providerName).toBe("Llama.cpp (http://127.0.0.1:8080)");
26
+ });
27
+ });
28
+
29
+ describe("Server fetchModels", () => {
30
+ it("should call the /models endpoint", async () => {
31
+ mockRpc.mockResolvedValueOnce({
32
+ data: [{ id: "model1" }],
33
+ models: [{ id: "model1" }],
34
+ object: "list",
35
+ });
36
+
37
+ const server = createMockServer();
38
+ const result = await server.fetchModels();
39
+
40
+ expect(result).toEqual({
41
+ data: [{ id: "model1" }],
42
+ models: [{ id: "model1" }],
43
+ object: "list",
44
+ });
45
+ expect(mockRpc).toHaveBeenCalledWith("/v1/models");
46
+ });
47
+ });
48
+
49
+ describe("Server fetchModelProps", () => {
50
+ it("should call the /props endpoint with model id", async () => {
51
+ mockRpc.mockResolvedValueOnce({
52
+ is_sleeping: false,
53
+ default_generation_settings: {},
54
+ total_slots: 1,
55
+ model_alias: "test",
56
+ model_path: "/path/to/model.gguf",
57
+ modalities: { vision: false, audio: false },
58
+ media_marker: "",
59
+ endpoint_slots: false,
60
+ endpoint_props: false,
61
+ endpoint_metrics: false,
62
+ webui: false,
63
+ webui_settings: {},
64
+ chat_template: "",
65
+ chat_template_caps: {},
66
+ bos_token: "",
67
+ eos_token: "",
68
+ build_info: "",
69
+ });
70
+
71
+ const server = createMockServer();
72
+ const result = await server.fetchModelProps("test-model");
73
+
74
+ expect(result.is_sleeping).toBe(false);
75
+ expect(mockRpc).toHaveBeenCalledWith(
76
+ "/props?model=test-model&autoload=false",
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("Server fetchServerHealth", () => {
82
+ it("should call the /health endpoint", async () => {
83
+ mockRpc.mockResolvedValueOnce({ status: "ok" });
84
+
85
+ const server = createMockServer();
86
+ const result = await server.fetchServerHealth();
87
+
88
+ expect(result).toEqual({ status: "ok" });
89
+ expect(mockRpc).toHaveBeenCalledWith("/health");
90
+ });
91
+ });
92
+
93
+ describe("Server fetchServerProps", () => {
94
+ it("should call the /props endpoint without model", async () => {
95
+ mockRpc.mockResolvedValueOnce({
96
+ role: "router",
97
+ default_generation_settings: {},
98
+ total_slots: 2,
99
+ model_alias: "",
100
+ model_path: "",
101
+ modalities: { vision: false, audio: false },
102
+ media_marker: "",
103
+ endpoint_slots: false,
104
+ endpoint_props: false,
105
+ endpoint_metrics: false,
106
+ webui: false,
107
+ webui_settings: {},
108
+ chat_template: "",
109
+ chat_template_caps: {},
110
+ bos_token: "",
111
+ eos_token: "",
112
+ build_info: "",
113
+ is_sleeping: false,
114
+ });
115
+
116
+ const server = createMockServer();
117
+ const result = await server.fetchServerProps();
118
+
119
+ expect(result.role).toBe("router");
120
+ expect(mockRpc).toHaveBeenCalledWith("/props?autoload=false");
121
+ });
122
+ });
123
+
124
+ describe("Server postRequest", () => {
125
+ it("should call /models/load with model in body", async () => {
126
+ mockRpc.mockResolvedValueOnce({});
127
+
128
+ const server = createMockServer();
129
+ await server.postRequest("load", "test-model");
130
+
131
+ expect(mockRpc).toHaveBeenCalledWith("/models/load", {
132
+ model: "test-model",
133
+ });
134
+ });
135
+
136
+ it("should call /models/unload with model in body", async () => {
137
+ mockRpc.mockResolvedValueOnce({});
138
+
139
+ const server = createMockServer();
140
+ await server.postRequest("unload", "test-model");
141
+
142
+ expect(mockRpc).toHaveBeenCalledWith("/models/unload", {
143
+ model: "test-model",
144
+ });
145
+ });
146
+ });
147
+
148
+ describe("Server isReady", () => {
149
+ it("should return true when health status is ok", async () => {
150
+ mockRpc.mockResolvedValueOnce({ status: "ok" });
151
+
152
+ const server = createMockServer();
153
+ const ready = await server.isReady();
154
+
155
+ expect(ready).toBe(true);
156
+ });
157
+
158
+ it("should return false when health check fails", async () => {
159
+ mockRpc.mockRejectedValueOnce(new Error("connection refused"));
160
+
161
+ const server = createMockServer();
162
+ const ready = await server.isReady();
163
+
164
+ expect(ready).toBe(false);
165
+ });
166
+
167
+ it("should return false when health status is not ok", async () => {
168
+ mockRpc.mockResolvedValueOnce({ status: "error" });
169
+
170
+ const server = createMockServer();
171
+ const ready = await server.isReady();
172
+
173
+ expect(ready).toBe(false);
174
+ });
175
+ });
@@ -0,0 +1,117 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ServerManager } from "../src/managers/server";
3
+ import { BaseModel } from "../src/models/baseModel";
4
+ import { Server } from "../src/server";
5
+ import { createMockServer, mockRpc } from "./mocks";
6
+
7
+ const mockPi = {
8
+ registerProvider: vi.fn(),
9
+ registerCommand: vi.fn(),
10
+ setModel: vi.fn(),
11
+ };
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ mockRpc.mockResolvedValue({});
16
+ });
17
+
18
+ describe("Server", () => {
19
+ it("should generate provider IDs from URLs", () => {
20
+ const server1 = new Server("http://127.0.0.1:8080");
21
+ expect(server1.providerId).toBe("llama-server=http://127.0.0.1:8080");
22
+ const server2 = new Server("http://10.0.0.5:8080");
23
+ expect(server2.providerId).toBe("llama-server=http://10.0.0.5:8080");
24
+ const server3 = new Server("http://127.0.0.1");
25
+ expect(server3.providerId).toBe("llama-server=http://127.0.0.1");
26
+ const server4 = new Server("http://127.0.0.1:80");
27
+ expect(server4.providerId).toBe("llama-server=http://127.0.0.1:80");
28
+ const server5 = new Server("https://127.0.0.1:443");
29
+ expect(server5.providerId).toBe("llama-server=https://127.0.0.1:443");
30
+ });
31
+
32
+ it("should generate provider names from URLs", () => {
33
+ const server1 = new Server("http://127.0.0.1:8080");
34
+ expect(server1.providerName).toBe("Llama.cpp (http://127.0.0.1:8080)");
35
+ const server2 = new Server("http://10.0.0.5:8080");
36
+ expect(server2.providerName).toBe("Llama.cpp (http://10.0.0.5:8080)");
37
+ });
38
+ });
39
+
40
+ describe("ServerManager", () => {
41
+ it("should register providers for all servers", async () => {
42
+ const mockModel = {
43
+ name: "test-model",
44
+ id: "test-model",
45
+ toProviderConfig: vi.fn().mockResolvedValue({ id: "test-model" }),
46
+ } as unknown as BaseModel;
47
+ mockRpc.mockResolvedValue({
48
+ data: [mockModel],
49
+ object: "list",
50
+ });
51
+
52
+ const server1 = createMockServer({
53
+ baseUrl: "http://127.0.0.1:8080",
54
+ apiKey: "key-1",
55
+ providerId: "llama-server=http://127.0.0.1:8080",
56
+ providerName: "Llama.cpp (http://127.0.0.1:8080)",
57
+ });
58
+ const server2 = createMockServer({
59
+ baseUrl: "http://127.0.0.1:8081",
60
+ apiKey: "key-2",
61
+ providerId: "llama-server=http://127.0.0.1:8081",
62
+ providerName: "Llama.cpp (http://127.0.0.1:8081)",
63
+ });
64
+ const manager = new ServerManager([server1, server2] as any);
65
+
66
+ await manager.registerAllProviders(mockPi as any);
67
+
68
+ expect(mockPi.registerProvider).toHaveBeenCalledTimes(2);
69
+ expect(mockPi.registerProvider).toHaveBeenCalledWith(
70
+ "llama-server=http://127.0.0.1:8080",
71
+ {
72
+ name: "Llama.cpp (http://127.0.0.1:8080)",
73
+ baseUrl: "http://127.0.0.1:8080",
74
+ api: "openai-completions",
75
+ apiKey: "key-1",
76
+ models: [{ id: "test-model" }],
77
+ },
78
+ );
79
+ expect(mockPi.registerProvider).toHaveBeenCalledWith(
80
+ "llama-server=http://127.0.0.1:8081",
81
+ {
82
+ name: "Llama.cpp (http://127.0.0.1:8081)",
83
+ baseUrl: "http://127.0.0.1:8081",
84
+ api: "openai-completions",
85
+ apiKey: "key-2",
86
+ models: [{ id: "test-model" }],
87
+ },
88
+ );
89
+ });
90
+
91
+ it("should return all models from all servers", () => {
92
+ const mockModel1 = {
93
+ name: "model-1",
94
+ id: "model-1",
95
+ } as unknown as BaseModel;
96
+ const mockModel2 = {
97
+ name: "model-2",
98
+ id: "model-2",
99
+ } as unknown as BaseModel;
100
+ const server1 = createMockServer({
101
+ baseUrl: "http://127.0.0.1:8080",
102
+ });
103
+ const server2 = createMockServer({
104
+ baseUrl: "http://127.0.0.1:8081",
105
+ });
106
+ const manager = new ServerManager([
107
+ { ...server1, models: [mockModel1] } as any,
108
+ { ...server2, models: [mockModel2] } as any,
109
+ ] as any);
110
+
111
+ const allModels = manager.getAllModels();
112
+
113
+ expect(allModels).toHaveLength(2);
114
+ expect(allModels[0]).toBe(mockModel1);
115
+ expect(allModels[1]).toBe(mockModel2);
116
+ });
117
+ });
@@ -1,29 +1,26 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
2
  import { Mode } from "../src/enums/mode";
3
3
  import { Status } from "../src/enums/status";
4
- import { ModelProperty } from "../src/interfaces/endpoints/models";
4
+ import { DataProperty } from "../src/interfaces/endpoints/models";
5
5
  import { SingleModel } from "../src/models/singleModel";
6
-
7
- const mockRpc = vi.fn();
8
-
9
- vi.mock("../src/tools/retriever", () => ({
10
- rpc: (...args: unknown[]) => mockRpc(...args),
11
- isServerReady: vi.fn(),
12
- listModels: vi.fn(),
13
- }));
6
+ import { createMockServer, mockRpc } from "./mocks";
14
7
 
15
8
  beforeEach(() => {
16
- mockRpc.mockClear();
9
+ mockRpc.mockReset();
17
10
  });
18
11
 
19
- const createModel = (extra: Partial<ModelProperty> = {}): SingleModel =>
20
- new SingleModel({
21
- id: "test",
22
- tags: [],
23
- object: "model",
24
- owned_by: "test",
25
- created: Date.now(),
26
- });
12
+ const createModel = (extra: Partial<DataProperty> = {}): SingleModel =>
13
+ new SingleModel(
14
+ {
15
+ id: "test",
16
+ tags: [],
17
+ object: "model",
18
+ owned_by: "test",
19
+ created: Date.now(),
20
+ ...extra,
21
+ },
22
+ createMockServer(),
23
+ );
27
24
 
28
25
  describe("SingleModel mode", () => {
29
26
  it("should always return SINGLE mode", () => {
@@ -34,21 +31,16 @@ describe("SingleModel mode", () => {
34
31
 
35
32
  describe("SingleModel capabilities", () => {
36
33
  it("should detect image capability when multimodal is in capabilities", async () => {
37
- mockRpc.mockResolvedValueOnce({
38
- models: [{ id: "test", capabilities: ["multimodal"] }],
39
- });
34
+ mockRpc.mockResolvedValueOnce({ modalities: { vision: true } });
40
35
 
41
36
  const model = createModel();
42
37
  const capabilities = await model.getCapabilities();
43
38
 
44
39
  expect(capabilities).toEqual(["text", "image"]);
45
- expect(mockRpc).toHaveBeenCalledWith("/models");
46
40
  });
47
41
 
48
42
  it("should detect text-only capability when multimodal is not in capabilities", async () => {
49
- mockRpc.mockResolvedValueOnce({
50
- models: [{ id: "test", capabilities: [] }],
51
- });
43
+ mockRpc.mockResolvedValueOnce({ modalities: { vision: false } });
52
44
 
53
45
  const model = createModel();
54
46
  const capabilities = await model.getCapabilities();
@@ -81,8 +73,8 @@ describe("SingleModel getStatus", () => {
81
73
  });
82
74
 
83
75
  describe("SingleModel getContextSize", () => {
84
- it("should return n_ctx from /models endpoint meta", async () => {
85
- mockRpc.mockResolvedValueOnce({
76
+ it("should return n_ctx from /v1/models endpoint meta", async () => {
77
+ mockRpc.mockResolvedValue({
86
78
  data: [{ id: "test", meta: { n_ctx: 8192 } }],
87
79
  });
88
80
 
@@ -90,6 +82,6 @@ describe("SingleModel getContextSize", () => {
90
82
  const ctxSize = await model.getContextSize();
91
83
 
92
84
  expect(ctxSize).toBe(8192);
93
- expect(mockRpc).toHaveBeenCalledWith("/models");
85
+ expect(mockRpc).toHaveBeenCalledWith("/v1/models");
94
86
  });
95
87
  });
@@ -1,228 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- SessionBeforeSwitchEvent,
6
- } from "@earendil-works/pi-coding-agent";
7
- import { PROVIDER_ID, PROVIDER_NAME, READABLE_TIMEOUT } from "../constants";
8
- import { Action } from "../enums/action";
9
- import { Mode } from "../enums/mode";
10
- import { Status } from "../enums/status";
11
- import { BaseModel } from "../models/baseModel";
12
- import { resolveUrl } from "../tools/resolver";
13
-
14
- // In-flight model reference — handler gates on this.
15
- let inflightModel: BaseModel | null = null;
16
-
17
- export const resetInflightModel = () => (inflightModel = null);
18
-
19
- /**
20
- * Session-switch handler. Registered once at extension init.
21
- * Only notifies if a model load is actually in-flight.
22
- */
23
- export const onSessionBeforeSwitch = async (
24
- _event: SessionBeforeSwitchEvent,
25
- ctx: ExtensionContext,
26
- ) => {
27
- if (!inflightModel) return;
28
-
29
- const messages = [
30
- `Session change detected while model '${inflightModel.name}' was still loading.`,
31
- "Model load will continue in the background, but UI might not update.",
32
- "",
33
- "Verify that your new model is loaded, or use /models to re-select it afterwards.",
34
- ];
35
- ctx.ui.notify(messages.join("\n"), "warning");
36
-
37
- // Show the notification for a reasonable amount of time
38
- await new Promise((r) => setTimeout(r, READABLE_TIMEOUT));
39
- };
40
-
41
- /**
42
- * Select a model from the list. Returns null if user cancels.
43
- *
44
- * @param ctx Pi context
45
- * @param models A list of models
46
- * @returns The selected model
47
- */
48
- const selectModel = async (
49
- ctx: ExtensionCommandContext,
50
- models: BaseModel[],
51
- ): Promise<BaseModel | null> => {
52
- const labels = await Promise.all(models.map((m) => m.getLabel()));
53
- const choice = await ctx.ui.select(`${PROVIDER_NAME} models:`, labels);
54
- if (!choice) return null;
55
- const idx = labels.indexOf(choice);
56
- return models[idx];
57
- };
58
-
59
- /**
60
- * Get available actions for a model based on its mode and status.
61
- *
62
- * @param model The selected model
63
- * @returns The array of available actions for the given model status
64
- */
65
- const getActionsForModel = async (model: BaseModel): Promise<Array<Action>> => {
66
- const routerModeActions: Record<Status, Array<Action>> = {
67
- [Status.LOADED]: [Action.SWITCH, Action.UNLOAD, Action.INFO, Action.CANCEL],
68
- [Status.LOADING]: [Action.INFO, Action.CANCEL],
69
- [Status.FAILED]: [Action.RETRY, Action.CANCEL],
70
- [Status.SLEEPING]: [
71
- Action.SWITCH,
72
- Action.UNLOAD,
73
- Action.INFO,
74
- Action.CANCEL,
75
- ],
76
- [Status.UNLOADED]: [Action.LOAD, Action.CANCEL],
77
- };
78
-
79
- const singleModeActions: Record<Status, Array<Action>> = {
80
- [Status.LOADED]: [Action.INFO, Action.CANCEL],
81
- [Status.LOADING]: [Action.CANCEL],
82
- [Status.FAILED]: [Action.CANCEL],
83
- [Status.SLEEPING]: [Action.INFO, Action.CANCEL],
84
- [Status.UNLOADED]: [Action.CANCEL],
85
- };
86
-
87
- const allActions =
88
- model.mode === Mode.ROUTER ? routerModeActions : singleModeActions;
89
-
90
- const status = await model.getStatus();
91
- return allActions[status];
92
- };
93
-
94
- /**
95
- * Selects an action for a model.
96
- *
97
- * @param ctx Pi context
98
- * @param model The selected model
99
- * @param actions Possible actions to execute
100
- * @returns The action, or null if user cancels
101
- */
102
- const selectAction = async (
103
- ctx: ExtensionCommandContext,
104
- model: BaseModel,
105
- actions: Array<Action>,
106
- ): Promise<Action | null> => {
107
- const labels = actions.map((a) => String(a));
108
- const choice = await ctx.ui.select(`${model.name}`, labels);
109
- if (!choice) return null;
110
-
111
- const idx = labels.indexOf(choice);
112
- return actions[idx];
113
- };
114
-
115
- /**
116
- * Handles the menu for model selection
117
- * Loops: select model → select action → handle action.
118
- *
119
- * Escape on actions menu goes back to model selection.
120
- * Escape on model selection exits.
121
- *
122
- * @param ctx Pi context
123
- * @returns The action and model, if detected
124
- */
125
- const modelSelectionHandler = async (
126
- ctx: ExtensionCommandContext,
127
- models: BaseModel[],
128
- ): Promise<{ action: Action; model: BaseModel } | null> => {
129
- while (true) {
130
- // Select the model
131
- const model = await selectModel(ctx, models);
132
- if (!model) return null;
133
-
134
- // Select the action
135
- const actions = await getActionsForModel(model);
136
- const action = await selectAction(ctx, model, actions);
137
- if (action === null) {
138
- // Escape key pressed => back to model selection
139
- continue;
140
- }
141
-
142
- // Return the selected action and model
143
- return { action, model };
144
- }
145
- };
146
-
147
- /**
148
- * Handles the /models command when the server is unreachable.
149
- *
150
- * @param ctx The context used by Pi
151
- */
152
- export const notFoundCommand = async (
153
- ctx: ExtensionCommandContext,
154
- ): Promise<void> => {
155
- const url = await resolveUrl(ctx.cwd);
156
- ctx.ui.notify(`${PROVIDER_NAME} unreachable at ${url}`, "error");
157
- };
158
-
159
- /**
160
- * Handles the /models command
161
- *
162
- * @param args Arguments passed to the command
163
- * @param ctx The context used by Pi
164
- * @param pi The Pi extension
165
- * @param models List of available models
166
- */
167
- export const modelsCommand = async (
168
- ctx: ExtensionCommandContext,
169
- pi: ExtensionAPI,
170
- models: BaseModel[],
171
- ): Promise<void> => {
172
- const event = await modelSelectionHandler(ctx, models);
173
- if (!event) return;
174
-
175
- // Detect the model
176
- const { action, model } = event;
177
-
178
- // Action: Cancel
179
- if (!action || action === Action.CANCEL) return;
180
-
181
- // Action: Info
182
- if (action === Action.INFO) {
183
- const info = await model.getInfo();
184
- ctx.ui.notify(`${info}`, "info");
185
- return;
186
- }
187
-
188
- // Action: Unload
189
- if (action === Action.UNLOAD) {
190
- await model.unload();
191
- ctx.ui.notify(`Unloaded ${model.name}`, "info");
192
- return;
193
- }
194
-
195
- // Actions: Load/Switch/Retry
196
- const loadActions = [Action.LOAD, Action.SWITCH, Action.RETRY];
197
- if (loadActions.includes(action)) {
198
- ctx.ui.notify(`Loading ${model.name}...`, "info");
199
- inflightModel = model;
200
-
201
- const onSuccess = async () => {
202
- const piModel = ctx.modelRegistry.find(PROVIDER_ID, model.id);
203
- if (!piModel) {
204
- throw new Error(`Cannot find model ${model.name} in pi registry`);
205
- }
206
-
207
- if ((await model.getStatus()) === Status.FAILED) {
208
- throw new Error(`Failed to load model ${model.name}`);
209
- }
210
-
211
- await pi.setModel(piModel);
212
- ctx.ui.notify(`Model ${model.name} ready`, "info");
213
- };
214
-
215
- const onFailure = (err: any) => {
216
- const message = err instanceof Error ? err.message : String(err);
217
-
218
- try {
219
- ctx.ui.notify(message, "error");
220
- } catch {
221
- // ctx went stale between error and notification
222
- }
223
- };
224
-
225
- // Load the model without blocking the UI
226
- model.load().then(onSuccess).catch(onFailure).finally(resetInflightModel);
227
- }
228
- };
package/src/events.ts DELETED
@@ -1,26 +0,0 @@
1
- import { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { PROVIDER_ID } from "./constants";
3
- import { ModelSelectEvent } from "./interfaces/events";
4
- import { listModels } from "./tools/retriever";
5
-
6
- /**
7
- * Reacts to a new model event triggered by Pi
8
- * @param event Model selection event
9
- * @param ctx Pi context
10
- */
11
- export const onModelSelect = async (
12
- event: ModelSelectEvent,
13
- ctx: ExtensionContext,
14
- ) => {
15
- if (event.model.provider !== PROVIDER_ID) return;
16
-
17
- const models = await listModels();
18
- const model = models.find((m) => m.id === event.model.id);
19
- if (!model) return;
20
-
21
- ctx.ui.notify(`Loading ${model.name}...`, "info");
22
- await model
23
- .load()
24
- .then(() => ctx.ui.notify(`Model ${model.name} ready`, "info"))
25
- .catch(() => ctx.ui.notify(`Failed to load model ${model.name}`, "error"));
26
- };