pi-llama-cpp 0.5.1 → 0.7.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,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
+ });
package/tests/mocks.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { vi } from "vitest";
3
+ import { Mode } from "../src/enums/mode";
4
+ import { ServerStatus } from "../src/enums/serverStatus";
5
+ import { Status } from "../src/enums/status";
6
+ import { BaseModel } from "../src/models/baseModel";
7
+ import { Server } from "../src/server";
8
+
9
+ /** Shared mock RPC — each test configures it */
10
+ export const mockRpc = vi.fn();
11
+
12
+ /** Default mock server that assumes everything works */
13
+ export const createMockServer = (
14
+ overrides: Partial<Server & { apiKey?: string }> = {},
15
+ ): Server => {
16
+ const models: BaseModel[] = [];
17
+ const server: Partial<Server> = {
18
+ baseUrl: "http://127.0.0.1:8080",
19
+ models,
20
+ getApiKey: () => Promise.resolve(overrides.apiKey ?? ""),
21
+ fetchModels: () => mockRpc("/v1/models"),
22
+ fetchModelProps: (modelId: string) =>
23
+ mockRpc(`/props?model=${modelId}&autoload=false`),
24
+ fetchServerHealth: () => mockRpc("/health"),
25
+ fetchServerProps: () => mockRpc("/props?autoload=false"),
26
+ postRequest: (resource: "load" | "unload", model: string) =>
27
+ mockRpc(`/models/${resource}`, { model }),
28
+ isReady: async (timeout: number) => {
29
+ try {
30
+ const r = await mockRpc("/health");
31
+ return r.status === "ok"
32
+ ? ServerStatus.READY
33
+ : ServerStatus.UNREACHABLE;
34
+ } catch {
35
+ return ServerStatus.UNREACHABLE;
36
+ }
37
+ },
38
+ initialize: async () => {
39
+ const { data } = (await mockRpc("/v1/models")) as {
40
+ data: BaseModel[];
41
+ };
42
+ models.length = 0;
43
+ models.push(...(data ?? []));
44
+ },
45
+ ...overrides,
46
+ };
47
+ return server as Server;
48
+ };
49
+
50
+ /** Helper to create a mock BaseModel */
51
+ export const createMockModel = (
52
+ name: string,
53
+ overrides: Partial<BaseModel> = {},
54
+ ): BaseModel =>
55
+ ({
56
+ name,
57
+ id: name,
58
+ mode: Mode.ROUTER,
59
+ serverUrl: "http://127.0.0.1:8080",
60
+ capabilities: ["text"] as ["text"],
61
+ getStatus: vi.fn().mockResolvedValue(Status.LOADED),
62
+ getContextSize: vi.fn().mockResolvedValue(4096),
63
+ getInfo: vi.fn().mockResolvedValue(`Model: ${name}\nID: ${name}`),
64
+ load: vi.fn().mockResolvedValue(undefined),
65
+ unload: vi.fn().mockResolvedValue(undefined),
66
+ toProviderConfig: vi.fn().mockResolvedValue({}),
67
+ getLabel: vi.fn().mockResolvedValue(name),
68
+ ...overrides,
69
+ }) as unknown as BaseModel;
70
+
71
+ /** Create a mock extension context */
72
+ export const createMockCtx = (
73
+ selectFn: (prompt: string, options: string[]) => string | null,
74
+ ) => ({
75
+ cwd: "/tmp/test",
76
+ ui: {
77
+ select: vi.fn(selectFn),
78
+ notify: vi.fn(),
79
+ theme: {
80
+ fg: (color: string, text: string) => text,
81
+ },
82
+ },
83
+ modelRegistry: {
84
+ find: vi.fn().mockReturnValue({ id: "test-model-id" }),
85
+ },
86
+ });
87
+
88
+ /** Create a mock Pi instance */
89
+ export const createMockPi = () => ({
90
+ setModel: vi.fn(),
91
+ registerProvider: vi.fn(),
92
+ });
93
+
94
+ /** Create a mock Pi context for EventManager */
95
+ export const createMockPiContext = (notifyFn: ReturnType<typeof vi.fn>) =>
96
+ ({
97
+ ui: {
98
+ notify: notifyFn,
99
+ },
100
+ }) as any as ExtensionContext;
@@ -2,156 +2,193 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  API_KEY_PLACEHOLDER,
4
4
  DEFAULT_LLAMA_SERVER_URL,
5
- PROVIDER_ID,
6
5
  } from "../src/constants";
7
6
 
7
+ // Hoisted mock instances — survives vi.resetModules()
8
+ const mockAuthStorage = vi.hoisted(() => ({
9
+ reload: vi.fn(),
10
+ getApiKey: vi.fn(),
11
+ }));
12
+
13
+ const mockSettingsManager = vi.hoisted(() => ({
14
+ getProjectSettings: vi.fn(),
15
+ getGlobalSettings: vi.fn(),
16
+ }));
17
+
18
+ // Mock getAgentDir, AuthStorage, and SettingsManager before importing resolver
19
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
20
+ getAgentDir: vi.fn().mockReturnValue("/fake/agent/dir"),
21
+ AuthStorage: {
22
+ create: vi.fn().mockReturnValue(mockAuthStorage),
23
+ },
24
+ SettingsManager: {
25
+ create: vi.fn().mockReturnValue(mockSettingsManager),
26
+ },
27
+ }));
28
+
29
+ vi.mock("node:fs/promises", () => ({
30
+ readFile: vi.fn(),
31
+ }));
32
+
33
+ // Import mocked modules
34
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
35
+ import { readFile } from "node:fs/promises";
36
+ import { ConfigResolver } from "../src/resolver";
37
+
8
38
  describe("URL resolution fallback chain", () => {
9
- beforeEach(() => {
10
- vi.clearAllMocks();
11
- vi.resetModules();
12
- });
39
+ const mockReadFile = vi.mocked(readFile);
40
+ const mockGetAgentDir = vi.mocked(getAgentDir);
41
+ const mockGetProjectSettings = vi.mocked(
42
+ mockSettingsManager.getProjectSettings,
43
+ );
44
+ const mockGetGlobalSettings = vi.mocked(
45
+ mockSettingsManager.getGlobalSettings,
46
+ );
13
47
 
14
48
  afterEach(() => {
15
49
  delete process.env.LLAMA_SERVER_URL;
50
+ vi.resetModules();
16
51
  });
17
52
 
18
- it("should return default URL when no config is found", async () => {
19
- vi.doMock("node:fs/promises", () => ({
20
- access: vi.fn().mockRejectedValue(new Error("ENOENT")),
21
- constants: { F_OK: 0 },
22
- readFile: vi.fn().mockResolvedValue(""),
23
- }));
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ mockGetAgentDir.mockReturnValue("/fake/agent/dir");
56
+ // Default: no settings found
57
+ mockGetProjectSettings.mockReturnValue({});
58
+ mockGetGlobalSettings.mockReturnValue({});
59
+ });
24
60
 
25
- const { resolveUrl } = await import("../src/tools/resolver");
26
- const result = await resolveUrl("/tmp/test-project");
61
+ it("should return default URL when no config is found", async () => {
62
+ const resolver = new ConfigResolver();
63
+ const result = await resolver.resolveUrls();
27
64
 
28
- expect(result).toBe(DEFAULT_LLAMA_SERVER_URL);
65
+ expect(result).toEqual([DEFAULT_LLAMA_SERVER_URL]);
29
66
  });
30
67
 
31
68
  it("should prioritize project config over env variable", async () => {
32
- vi.doMock("node:fs/promises", () => ({
33
- access: vi.fn().mockImplementation(async (path: string) => {
34
- if (path.includes("llama-server.json")) return undefined;
35
- throw new Error("ENOENT");
36
- }),
37
- constants: { F_OK: 0 },
38
- readFile: vi
39
- .fn()
40
- .mockResolvedValue(JSON.stringify({ url: "http://localhost:9999" })),
41
- }));
42
-
69
+ mockGetProjectSettings.mockReturnValue({
70
+ llamaServerUrl: "http://localhost:9999",
71
+ });
43
72
  process.env.LLAMA_SERVER_URL = "http://env-url:8080";
44
73
 
45
- const { resolveUrl } = await import("../src/tools/resolver");
46
- const result = await resolveUrl("/tmp/test-project");
74
+ const resolver = new ConfigResolver();
75
+ const result = await resolver.resolveUrls();
47
76
 
48
- expect(result).toBe("http://localhost:9999");
77
+ expect(result).toEqual(["http://localhost:9999"]);
49
78
  });
50
79
 
51
80
  it("should use env variable when no project config exists", async () => {
52
- vi.doMock("node:fs/promises", () => ({
53
- access: vi.fn().mockRejectedValue(new Error("ENOENT")),
54
- constants: { F_OK: 0 },
55
- readFile: vi.fn().mockResolvedValue(""),
56
- }));
57
-
81
+ mockGetProjectSettings.mockReturnValue({});
58
82
  process.env.LLAMA_SERVER_URL = "http://env-url:8080";
59
83
 
60
- const { resolveUrl } = await import("../src/tools/resolver");
61
- const result = await resolveUrl("/tmp/test-project");
84
+ const resolver = new ConfigResolver();
85
+ const result = await resolver.resolveUrls();
62
86
 
63
- expect(result).toBe("http://env-url:8080");
87
+ expect(result).toEqual(["http://env-url:8080"]);
64
88
  });
65
89
 
66
90
  it("should use global settings when no project config or env exists", async () => {
67
- vi.doMock("node:fs/promises", () => ({
68
- access: vi.fn().mockImplementation(async (path: string) => {
69
- if (path.includes("settings.json")) return undefined;
70
- throw new Error("ENOENT");
71
- }),
72
- constants: { F_OK: 0 },
73
- readFile: vi
74
- .fn()
75
- .mockResolvedValue(
76
- JSON.stringify({ llamaServerUrl: "http://global:8080" }),
77
- ),
78
- }));
79
-
80
- const { resolveUrl } = await import("../src/tools/resolver");
81
- const result = await resolveUrl("/tmp/test-project");
82
-
83
- expect(result).toBe("http://global:8080");
91
+ mockGetProjectSettings.mockReturnValue({});
92
+ mockGetGlobalSettings.mockReturnValue({
93
+ llamaServerUrl: "http://global:8080",
94
+ });
95
+
96
+ const resolver = new ConfigResolver();
97
+ const result = await resolver.resolveUrls();
98
+
99
+ expect(result).toEqual(["http://global:8080"]);
84
100
  });
85
101
 
86
102
  it("should strip trailing slashes from resolved URL", async () => {
87
- vi.doMock("node:fs/promises", () => ({
88
- access: vi.fn().mockImplementation(async (path: string) => {
89
- if (path.includes("llama-server.json")) return undefined;
90
- throw new Error("ENOENT");
91
- }),
92
- constants: { F_OK: 0 },
93
- readFile: vi
94
- .fn()
95
- .mockResolvedValue(JSON.stringify({ url: "http://localhost:8080/" })),
96
- }));
97
-
98
- const { resolveUrl } = await import("../src/tools/resolver");
99
- const result = await resolveUrl("/tmp/test-project");
100
-
101
- expect(result).toBe("http://localhost:8080");
103
+ mockGetProjectSettings.mockReturnValue({
104
+ llamaServerUrl: "http://localhost:8080/",
105
+ });
106
+
107
+ const resolver = new ConfigResolver();
108
+ const result = await resolver.resolveUrls();
109
+
110
+ expect(result).toEqual(["http://localhost:8080"]);
111
+ });
112
+
113
+ it("should cache the resolved URL on subsequent calls", async () => {
114
+ mockGetProjectSettings.mockReturnValue({
115
+ llamaServerUrl: "http://first:8080",
116
+ });
117
+
118
+ const resolver = new ConfigResolver();
119
+ const result1 = await resolver.resolveUrls();
120
+ const result2 = await resolver.resolveUrls();
121
+
122
+ expect(result1).toEqual(["http://first:8080"]);
123
+ expect(result2).toEqual(["http://first:8080"]);
124
+ });
125
+
126
+ it("should handle multiple URLs separated by semicolons", async () => {
127
+ mockGetProjectSettings.mockReturnValue({
128
+ llamaServerUrl: "http://first:8080;http://second:9090/",
129
+ });
130
+
131
+ const resolver = new ConfigResolver();
132
+ const result = await resolver.resolveUrls();
133
+
134
+ expect(result).toEqual(["http://first:8080", "http://second:9090"]);
102
135
  });
103
136
  });
104
137
 
105
138
  describe("API key resolution", () => {
139
+ const mockGetAgentDir = vi.mocked(getAgentDir);
140
+
141
+ afterEach(() => {
142
+ vi.resetModules();
143
+ });
144
+
106
145
  beforeEach(() => {
107
146
  vi.clearAllMocks();
108
- vi.resetModules();
147
+ mockGetAgentDir.mockReturnValue("/fake/agent/dir");
148
+ mockAuthStorage.reload.mockReturnValue(undefined);
149
+ mockAuthStorage.getApiKey.mockResolvedValue(undefined);
109
150
  });
110
151
 
111
152
  it("should return placeholder when auth file does not exist", async () => {
112
- vi.doMock("node:fs/promises", () => ({
113
- access: vi.fn().mockRejectedValue(new Error("ENOENT")),
114
- constants: { F_OK: 0 },
115
- readFile: vi.fn().mockResolvedValue(""),
116
- }));
153
+ mockAuthStorage.getApiKey.mockResolvedValue(undefined);
117
154
 
118
- const { resolveApiKey } = await import("../src/tools/resolver");
119
- const result = await resolveApiKey();
155
+ const resolver = new ConfigResolver();
156
+ const result = await resolver.resolveApiKey(
157
+ "llama-server=http://127.0.0.1:8080",
158
+ );
120
159
 
121
- expect(result).toBe(API_KEY_PLACEHOLDER);
160
+ expect(result).toEqual(API_KEY_PLACEHOLDER);
122
161
  });
123
162
 
124
163
  it("should return placeholder when provider key is missing", async () => {
125
- vi.doMock("node:fs/promises", () => ({
126
- access: vi.fn().mockResolvedValue(undefined),
127
- constants: { F_OK: 0 },
128
- readFile: vi
129
- .fn()
130
- .mockResolvedValue(
131
- JSON.stringify({ "other-provider": { key: "other-key" } }),
132
- ),
133
- }));
134
-
135
- const { resolveApiKey } = await import("../src/tools/resolver");
136
- const result = await resolveApiKey();
137
-
138
- expect(result).toBe(API_KEY_PLACEHOLDER);
164
+ mockAuthStorage.getApiKey.mockResolvedValue(undefined);
165
+
166
+ const resolver = new ConfigResolver();
167
+ const result = await resolver.resolveApiKey(
168
+ "llama-server=http://127.0.0.1:8080",
169
+ );
170
+
171
+ expect(result).toEqual(API_KEY_PLACEHOLDER);
139
172
  });
140
173
 
141
174
  it("should return the provider key when present", async () => {
142
- vi.doMock("node:fs/promises", () => ({
143
- access: vi.fn().mockResolvedValue(undefined),
144
- constants: { F_OK: 0 },
145
- readFile: vi
146
- .fn()
147
- .mockResolvedValue(
148
- JSON.stringify({ [PROVIDER_ID]: { key: "test-api-key" } }),
149
- ),
150
- }));
151
-
152
- const { resolveApiKey } = await import("../src/tools/resolver");
153
- const result = await resolveApiKey();
154
-
155
- expect(result).toBe("test-api-key");
175
+ mockAuthStorage.getApiKey.mockResolvedValue("test-api-key");
176
+
177
+ const resolver = new ConfigResolver();
178
+ const result = await resolver.resolveApiKey(
179
+ "llama-server=http://127.0.0.1:8080",
180
+ );
181
+
182
+ expect(result).toEqual("test-api-key");
183
+ });
184
+
185
+ it("should call reload before each getApiKey", async () => {
186
+ mockAuthStorage.getApiKey.mockResolvedValue("cached-key");
187
+
188
+ const resolver = new ConfigResolver();
189
+ await resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
190
+ await resolver.resolveApiKey("llama-server=http://127.0.0.1:8080");
191
+
192
+ expect(mockAuthStorage.reload).toHaveBeenCalledTimes(2);
156
193
  });
157
194
  });