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.
package/src/server.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { PROVIDER_NAME, PROVIDER_PREFIX } from "./constants";
2
+ import { Mode } from "./enums/mode";
3
+ import { HealthEndpoint } from "./interfaces/endpoints/health";
4
+ import { ModelsEndpoint } from "./interfaces/endpoints/models";
5
+ import { PropsEndpoint } from "./interfaces/endpoints/props";
6
+ import { BaseModel } from "./models/baseModel";
7
+ import { LegacyModel } from "./models/legacyModel";
8
+ import { RouterModel } from "./models/routerModel";
9
+ import { SingleModel } from "./models/singleModel";
10
+ import { ConfigResolver } from "./resolver";
11
+
12
+ export class Server {
13
+ readonly models: BaseModel[] = [];
14
+
15
+ constructor(readonly baseUrl: string) {}
16
+
17
+ /**
18
+ * Generates a unique provider ID from a server URL.
19
+ */
20
+ get providerId(): string {
21
+ return `${PROVIDER_PREFIX}=${this.baseUrl}`;
22
+ }
23
+
24
+ /**
25
+ * Generates a human-readable provider name from a server URL.
26
+ */
27
+ get providerName(): string {
28
+ return `${PROVIDER_NAME} (${this.baseUrl})`;
29
+ }
30
+
31
+ /**
32
+ * Retrieves the API key from the resolver
33
+ * @returns The API key
34
+ */
35
+ async getApiKey(): Promise<string> {
36
+ return await new ConfigResolver().resolveApiKey(this.providerId);
37
+ }
38
+
39
+ /**
40
+ * Fetches models from the server and populates {@link models}
41
+ */
42
+ async initialize() {
43
+ const { data } = await this.fetchModels();
44
+ const mode = await this.detectServerMode();
45
+
46
+ // Setup models
47
+ const modelCtor = {
48
+ [Mode.ROUTER]: RouterModel,
49
+ [Mode.LEGACY]: LegacyModel,
50
+ [Mode.SINGLE]: SingleModel,
51
+ }[mode];
52
+
53
+ const models: BaseModel[] = data
54
+ .map((m) => new modelCtor(m, this))
55
+ .sort((a, b) => (a.id > b.id ? 1 : a.id === b.id ? 0 : -1));
56
+
57
+ this.models.length = 0;
58
+ this.models.push(...models);
59
+ }
60
+
61
+ /**
62
+ * Detects the mode of the server
63
+ *
64
+ * @returns The detected mode
65
+ */
66
+ private async detectServerMode(): Promise<Mode> {
67
+ const { role } = await this.fetchServerProps();
68
+ const { data } = await this.fetchModels();
69
+
70
+ if (role === "router") return Mode.ROUTER;
71
+ if ("max_model_len" in data[0]) return Mode.LEGACY;
72
+ return Mode.SINGLE;
73
+ }
74
+
75
+ /**
76
+ * Detects if the server is ready
77
+ * @returns True if it's ready to work
78
+ */
79
+ async isReady(): Promise<boolean> {
80
+ try {
81
+ const { status } = await this.fetchServerHealth();
82
+ return status === "ok";
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Retrieves the health status of the server
90
+ *
91
+ * @returns The health status
92
+ */
93
+ async fetchServerHealth(): Promise<HealthEndpoint> {
94
+ return await this.rpc<HealthEndpoint>("/health");
95
+ }
96
+
97
+ /**
98
+ * Fetches models from the server
99
+ *
100
+ * @return The models from the server
101
+ */
102
+ async fetchModels(): Promise<ModelsEndpoint> {
103
+ return await this.rpc<ModelsEndpoint>("/v1/models");
104
+ }
105
+
106
+ /**
107
+ * Fetches general properties of the server
108
+ *
109
+ * @return The properties of the server
110
+ */
111
+ async fetchServerProps(): Promise<PropsEndpoint> {
112
+ return await this.rpc<PropsEndpoint>("/props?autoload=false");
113
+ }
114
+
115
+ /**
116
+ * Fetches properties of a specific model from the server
117
+ *
118
+ * @param modelId The ID of the model
119
+ * @return The properties of the specified model
120
+ */
121
+ async fetchModelProps(modelId: string): Promise<PropsEndpoint> {
122
+ return await this.rpc<PropsEndpoint>(
123
+ `/props?model=${modelId}&autoload=false`,
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Sends a request associated to a specific model from the server
129
+ *
130
+ * @param resource The specified resource ("load" | "unload")
131
+ * @param model The targeted model
132
+ */
133
+ async postRequest(
134
+ resource: "load" | "unload",
135
+ model: string,
136
+ ): Promise<ModelsEndpoint> {
137
+ return await this.rpc<ModelsEndpoint>(`/models/${resource}`, { model });
138
+ }
139
+
140
+ /**
141
+ * Makes an HTTP request to the llama-server and returns the parsed JSON response
142
+ *
143
+ * @param endpoint The endpoint path to fetch (e.g. "/health")
144
+ * @param body The optional request body for POST requests
145
+ * @returns The parsed JSON response from the server
146
+ */
147
+ private async rpc<T>(
148
+ endpoint: string,
149
+ body?: Record<string, unknown>,
150
+ ): Promise<T> {
151
+ const url = `${this.baseUrl}${endpoint}`;
152
+ const apiKey = await this.getApiKey();
153
+
154
+ const data = {
155
+ method: body ? "POST" : "GET",
156
+ headers: body ? { "Content-Type": "application/json" } : undefined,
157
+ body: body ? JSON.stringify(body) : undefined,
158
+ };
159
+
160
+ const res = await fetch(url, {
161
+ ...data,
162
+ headers: {
163
+ ...data.headers,
164
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
165
+ },
166
+ });
167
+
168
+ const response: T = await res.json();
169
+ return response;
170
+ }
171
+ }
@@ -1,152 +1,202 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { PROVIDER_ID, PROVIDER_NAME } from "../src/constants";
3
- import { CommandManager } from "../src/manager";
4
-
5
- // Mock modules at top level (vi.mock is hoisted)
6
- vi.mock("../src/tools/retriever", () => ({
7
- isServerReady: vi.fn(),
8
- listModels: vi.fn(),
9
- }));
10
-
11
- vi.mock("../src/tools/resolver", () => ({
12
- resolveUrl: vi.fn(),
13
- resolveApiKey: vi.fn(),
14
- }));
15
-
16
- // Import mocked functions after vi.mock
17
- import { resolveApiKey, resolveUrl } from "../src/tools/resolver";
18
- import { isServerReady, listModels } from "../src/tools/retriever";
19
-
20
- const mockPi = {
21
- registerProvider: vi.fn(),
22
- };
2
+ import { Action } from "../src/enums/action";
3
+ import { CommandManager } from "../src/managers/command";
4
+ import { ServerManager } from "../src/managers/server";
5
+ import {
6
+ createMockCtx,
7
+ createMockModel,
8
+ createMockPi,
9
+ createMockServer,
10
+ mockRpc,
11
+ } from "./mocks";
23
12
 
24
13
  beforeEach(() => {
25
14
  vi.clearAllMocks();
26
- (resolveUrl as any).mockResolvedValue("http://127.0.0.1:8080");
27
- (resolveApiKey as any).mockResolvedValue("test-key");
15
+ mockRpc.mockResolvedValue({ data: [] });
28
16
  });
29
17
 
30
18
  describe("CommandManager", () => {
31
- it("should register empty models when server is not ready", async () => {
32
- (isServerReady as any).mockResolvedValue(false);
33
-
34
- const manager = new CommandManager(mockPi as any);
35
- await manager.initialize();
36
-
37
- expect(mockPi.registerProvider).toHaveBeenCalledWith(PROVIDER_ID, {
38
- name: PROVIDER_NAME,
39
- baseUrl: "http://127.0.0.1:8080",
40
- api: "openai-completions",
41
- apiKey: "test-key",
42
- models: [],
43
- });
19
+ let serverManager: ServerManager;
20
+ let commandManager: CommandManager;
21
+ let mockPi: ReturnType<typeof createMockPi>;
22
+
23
+ beforeEach(() => {
24
+ mockPi = createMockPi();
25
+ serverManager = new ServerManager([]);
26
+ commandManager = new CommandManager(serverManager);
44
27
  });
45
28
 
46
- it("should update and register models when server is ready", async () => {
47
- const mockModel = {
48
- name: "test-model",
49
- id: "test-model",
50
- toProviderConfig: vi
51
- .fn()
52
- .mockResolvedValue({ id: "test-model", maxTokens: 32000 }),
53
- };
54
- (isServerReady as any).mockResolvedValue(true);
55
- (listModels as any).mockResolvedValue([mockModel]);
56
-
57
- const manager = new CommandManager(mockPi as any);
58
- await manager.initialize();
59
-
60
- expect(resolveUrl).toHaveBeenCalledWith(expect.any(String));
61
- expect(listModels).toHaveBeenCalled();
62
- expect(mockPi.registerProvider).toHaveBeenCalledWith(PROVIDER_ID, {
63
- name: PROVIDER_NAME,
64
- baseUrl: "http://127.0.0.1:8080",
65
- api: "openai-completions",
66
- apiKey: "test-key",
67
- models: [{ id: "test-model", maxTokens: 32000 }],
29
+ describe("getArgumentCompletions", () => {
30
+ it("should provide completions for /models", () => {
31
+ const completions = commandManager.getArgumentCompletions("");
32
+ expect(completions).toHaveLength(2);
33
+ expect(completions?.map((c) => c.value)).toEqual(["info", "unload"]);
68
34
  });
69
- });
70
35
 
71
- it("should call notFoundCommand when server is not ready in run()", async () => {
72
- (isServerReady as any).mockResolvedValue(false);
73
-
74
- const manager = new CommandManager(mockPi as any);
75
- await manager.run("", { ui: { notify: vi.fn() } } as any);
36
+ it("should filter completions by prefix", () => {
37
+ const completions = commandManager.getArgumentCompletions("u");
38
+ expect(completions).toHaveLength(1);
39
+ expect(completions?.[0].value).toBe("unload");
40
+ });
76
41
 
77
- expect(mockPi.registerProvider).not.toHaveBeenCalled();
42
+ it("should return null when no completions match", () => {
43
+ const completions = commandManager.getArgumentCompletions("zzz");
44
+ expect(completions).toBeNull();
45
+ });
78
46
  });
79
47
 
80
- it("should show info for all models when args is 'info'", async () => {
81
- const mockModel = {
82
- name: "test-model",
83
- id: "test-model",
84
- getInfo: vi.fn().mockResolvedValue("Model info for test-model"),
85
- toProviderConfig: vi.fn().mockResolvedValue({ id: "test-model" }),
86
- };
87
- (isServerReady as any).mockResolvedValue(true);
88
- (listModels as any).mockResolvedValue([mockModel]);
89
-
90
- const notifyFn = vi.fn();
91
- const manager = new CommandManager(mockPi as any);
92
- await manager.initialize();
93
- await manager.run("info", {
94
- ui: { notify: notifyFn, theme: { fg: (_c: string, t: string) => t } },
95
- } as any);
96
-
97
- expect(notifyFn).toHaveBeenCalledWith("Model info for test-model", "info");
98
- expect(listModels).toHaveBeenCalledOnce();
99
- });
48
+ describe("handleCommand", () => {
49
+ it("should unload all models when args is 'unload'", async () => {
50
+ const model1 = createMockModel("model-1");
51
+ const model2 = createMockModel("model-2");
52
+ const server = createMockServer({
53
+ baseUrl: "http://127.0.0.1:8080",
54
+ models: [model1, model2],
55
+ });
56
+ serverManager = new ServerManager([server] as any);
57
+ commandManager = new CommandManager(serverManager);
58
+
59
+ const ctx = {
60
+ ui: {
61
+ notify: vi.fn(),
62
+ theme: { fg: (_: string, text: string) => text },
63
+ },
64
+ } as any;
65
+
66
+ await commandManager.handleCommand("unload", ctx, mockPi as any);
67
+
68
+ expect(model1.unload).toHaveBeenCalled();
69
+ expect(model2.unload).toHaveBeenCalled();
70
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
71
+ "Unloaded all Llama.cpp models",
72
+ "info",
73
+ );
74
+ });
100
75
 
101
- it("should unload all models when args is 'unload'", async () => {
102
- const mockModel1 = {
103
- name: "model-1",
104
- id: "model-1",
105
- unload: vi.fn().mockResolvedValue(undefined),
106
- toProviderConfig: vi.fn().mockResolvedValue({ id: "model-1" }),
107
- };
108
- const mockModel2 = {
109
- name: "model-2",
110
- id: "model-2",
111
- unload: vi.fn().mockResolvedValue(undefined),
112
- toProviderConfig: vi.fn().mockResolvedValue({ id: "model-2" }),
113
- };
114
- (isServerReady as any).mockResolvedValue(true);
115
- (listModels as any).mockResolvedValue([mockModel1, mockModel2]);
116
-
117
- const notifyFn = vi.fn();
118
- const manager = new CommandManager(mockPi as any);
119
- await manager.initialize();
120
- await manager.run("unload", {
121
- ui: { notify: notifyFn },
122
- } as any);
123
-
124
- expect(mockModel1.unload).toHaveBeenCalled();
125
- expect(mockModel2.unload).toHaveBeenCalled();
126
- expect(notifyFn).toHaveBeenCalledWith(
127
- "Unloaded all Llama.cpp models",
128
- "info",
129
- );
76
+ it("should show model info when args is 'info'", async () => {
77
+ const model1 = createMockModel("model-1");
78
+ const model2 = createMockModel("model-2");
79
+ const server = createMockServer({
80
+ baseUrl: "http://127.0.0.1:8080",
81
+ models: [model1, model2],
82
+ });
83
+ serverManager = new ServerManager([server] as any);
84
+ commandManager = new CommandManager(serverManager);
85
+
86
+ const ctx = {
87
+ ui: {
88
+ notify: vi.fn(),
89
+ theme: { fg: (_: string, text: string) => text },
90
+ },
91
+ } as any;
92
+
93
+ await commandManager.handleCommand("info", ctx, mockPi as any);
94
+
95
+ expect(model1.getInfo).toHaveBeenCalled();
96
+ expect(model2.getInfo).toHaveBeenCalled();
97
+ });
130
98
  });
131
99
 
132
- it("should dispatch modelsCommand when args is empty", async () => {
133
- const mockModel = {
134
- name: "test-model",
135
- id: "test-model",
136
- getLabel: vi.fn().mockResolvedValue("test-model"),
137
- toProviderConfig: vi.fn().mockResolvedValue({ id: "test-model" }),
100
+ describe("/models interactive menu", () => {
101
+ const CHOICE = "model-a [Server: http://127.0.0.1:8080]";
102
+
103
+ /**
104
+ * Helper to create a CommandManager with mock servers and models.
105
+ */
106
+ const createCommandManager = (
107
+ models: ReturnType<typeof createMockModel>[],
108
+ ) => {
109
+ const mockPi = createMockPi();
110
+ const servers = models.map((model) =>
111
+ createMockServer({
112
+ baseUrl: model.serverUrl,
113
+ models: [model],
114
+ }),
115
+ );
116
+ const serverManager = new ServerManager(servers as any);
117
+ return {
118
+ commandManager: new CommandManager(serverManager),
119
+ serverManager,
120
+ mockPi,
121
+ };
138
122
  };
139
- (isServerReady as any).mockResolvedValue(true);
140
- (listModels as any).mockResolvedValue([mockModel]);
141
-
142
- const selectFn = vi.fn().mockReturnValue(null); // cancel immediately
143
- const manager = new CommandManager(mockPi as any);
144
- await manager.initialize();
145
- await manager.run("", {
146
- ui: { notify: vi.fn(), select: selectFn },
147
- } as any);
148
-
149
- // modelsCommand was called (select is invoked for model picking)
150
- expect(selectFn).toHaveBeenCalled();
123
+
124
+ it("should return early on cancel (null model selection)", async () => {
125
+ const models = [createMockModel("model-a")];
126
+ const { commandManager, mockPi } = createCommandManager(models);
127
+ const ctx = createMockCtx(() => null);
128
+
129
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
130
+
131
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("should show info when INFO action is selected", async () => {
135
+ const model = createMockModel("model-a");
136
+ const { commandManager, mockPi } = createCommandManager([model]);
137
+ let selectCallCount = 0;
138
+ const ctx = createMockCtx(() => {
139
+ selectCallCount++;
140
+ if (selectCallCount === 1) return CHOICE;
141
+ return Action.INFO;
142
+ });
143
+
144
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
145
+
146
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
147
+ "Model: model-a\nID: model-a",
148
+ "info",
149
+ );
150
+ });
151
+
152
+ it("should unload model when UNLOAD action is selected", async () => {
153
+ const model = createMockModel("model-a");
154
+ const { commandManager, mockPi } = createCommandManager([model]);
155
+ let selectCallCount = 0;
156
+ const ctx = createMockCtx(() => {
157
+ selectCallCount++;
158
+ if (selectCallCount === 1) return CHOICE;
159
+ return Action.UNLOAD;
160
+ });
161
+
162
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
163
+
164
+ expect(model.unload).toHaveBeenCalled();
165
+ expect(ctx.ui.notify).toHaveBeenCalledWith("Unloaded model-a", "info");
166
+ });
167
+
168
+ it("should switch model when SWITCH action is selected", async () => {
169
+ const model = createMockModel("model-a");
170
+ const { commandManager, mockPi } = createCommandManager([model]);
171
+ let selectCallCount = 0;
172
+ const ctx = createMockCtx(() => {
173
+ selectCallCount++;
174
+ if (selectCallCount === 1) return CHOICE;
175
+ return Action.SWITCH;
176
+ });
177
+
178
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
179
+
180
+ expect(mockPi.setModel).toHaveBeenCalled();
181
+ expect(ctx.ui.notify).toHaveBeenCalledWith("Model model-a ready", "info");
182
+ });
183
+
184
+ it("should loop back to model selection when action is cancelled", async () => {
185
+ const model = createMockModel("model-a");
186
+ const { commandManager, mockPi } = createCommandManager([model]);
187
+
188
+ let selectCallCount = 0;
189
+ const ctx = createMockCtx(() => {
190
+ selectCallCount++;
191
+ // 1st: select model-a, 2nd: cancel action, 3rd: cancel model => exit
192
+ if (selectCallCount === 1) return CHOICE;
193
+ return null;
194
+ });
195
+
196
+ await commandManager.handleCommand("", ctx as any, mockPi as any);
197
+
198
+ expect(ctx.ui.select).toHaveBeenCalledTimes(3);
199
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
200
+ });
151
201
  });
152
202
  });
@@ -0,0 +1,112 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { Mode } from "../src/enums/mode";
3
+ import { Status } from "../src/enums/status";
4
+ import { DataProperty } from "../src/interfaces/endpoints/models";
5
+ import { LegacyModel } from "../src/models/legacyModel";
6
+ import { createMockServer, mockRpc } from "./mocks";
7
+
8
+ beforeEach(() => {
9
+ mockRpc.mockReset();
10
+ });
11
+
12
+ const createModel = (extra: Partial<DataProperty> = {}): LegacyModel =>
13
+ new LegacyModel(
14
+ {
15
+ id: "test",
16
+ tags: [],
17
+ object: "model",
18
+ owned_by: "test",
19
+ created: Date.now(),
20
+ ...extra,
21
+ },
22
+ createMockServer(),
23
+ );
24
+
25
+ describe("LegacyModel mode", () => {
26
+ it("should always return LEGACY mode", () => {
27
+ const model = createModel();
28
+ expect(model.mode).toBe(Mode.LEGACY);
29
+ });
30
+ });
31
+
32
+ describe("LegacyModel capabilities", () => {
33
+ it("should detect image capability when multimodal is in capabilities", async () => {
34
+ mockRpc.mockResolvedValueOnce({ modalities: { vision: true } });
35
+
36
+ const model = createModel();
37
+ const capabilities = await model.getCapabilities();
38
+
39
+ expect(capabilities).toEqual(["text", "image"]);
40
+ });
41
+
42
+ it("should detect text-only capability when multimodal is not in capabilities", async () => {
43
+ mockRpc.mockResolvedValueOnce({ modalities: { vision: false } });
44
+
45
+ const model = createModel();
46
+ const capabilities = await model.getCapabilities();
47
+
48
+ expect(capabilities).toEqual(["text"]);
49
+ });
50
+ });
51
+
52
+ describe("LegacyModel getStatus", () => {
53
+ it("should return LOADED when not sleeping", async () => {
54
+ mockRpc.mockResolvedValueOnce({ is_sleeping: false });
55
+
56
+ const model = createModel();
57
+ const status = await model.getStatus();
58
+
59
+ expect(status).toBe(Status.LOADED);
60
+ expect(mockRpc).toHaveBeenCalledWith(
61
+ `/props?model=${model.id}&autoload=false`,
62
+ );
63
+ });
64
+
65
+ it("should return SLEEPING when is_sleeping is true", async () => {
66
+ mockRpc.mockResolvedValueOnce({ is_sleeping: true });
67
+
68
+ const model = createModel();
69
+ const status = await model.getStatus();
70
+
71
+ expect(status).toBe(Status.SLEEPING);
72
+ });
73
+ });
74
+
75
+ describe("LegacyModel getContextSize", () => {
76
+ it("should use max_model_len when it is non-zero", async () => {
77
+ mockRpc.mockResolvedValueOnce({ n_ctx: 4096 });
78
+ mockRpc.mockResolvedValueOnce({
79
+ data: [{ max_model_len: 8192 }],
80
+ });
81
+
82
+ const model = createModel();
83
+ const ctxSize = await model.getContextSize();
84
+
85
+ expect(ctxSize).toBe(8192);
86
+ expect(mockRpc).toHaveBeenCalledWith("/v1/models");
87
+ });
88
+
89
+ it("should fall back to n_ctx when max_model_len is 0", async () => {
90
+ mockRpc.mockResolvedValueOnce({ n_ctx: 4096 });
91
+ mockRpc.mockResolvedValueOnce({
92
+ data: [{ max_model_len: 0 }],
93
+ });
94
+
95
+ const model = createModel();
96
+ const ctxSize = await model.getContextSize();
97
+
98
+ expect(ctxSize).toBe(4096);
99
+ });
100
+
101
+ it("should return DEFAULT_CTX when both values are missing/null", async () => {
102
+ mockRpc.mockResolvedValueOnce({});
103
+ mockRpc.mockResolvedValueOnce({
104
+ data: [{ max_model_len: null }],
105
+ });
106
+
107
+ const model = createModel();
108
+ const ctxSize = await model.getContextSize();
109
+
110
+ expect(ctxSize).toBe(128000);
111
+ });
112
+ });