pi-llama-cpp 0.2.2 → 0.2.3
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/package.json +8 -3
- package/src/constants.ts +5 -0
- package/src/interfaces/auth.ts +1 -1
- package/src/models/baseModel.ts +12 -17
- package/src/models/routerModel.ts +1 -1
- package/src/tools/resolver.ts +15 -19
- package/tests/handlers.test.ts +159 -0
- package/tests/resolver.test.ts +157 -0
- package/tests/routerModel.test.ts +176 -0
- package/tests/singleModel.test.ts +123 -0
- package/vitest.config.ts +8 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-llama-cpp",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Pi extension for llama.cpp integration. Supports both router and single modes",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Pi extension for llama.cpp integration. Supports both router and single modes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"./src/index.ts"
|
|
24
24
|
]
|
|
25
25
|
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "vitest",
|
|
28
|
+
"test:run": "vitest run"
|
|
29
|
+
},
|
|
26
30
|
"prettier": {
|
|
27
31
|
"plugins": [
|
|
28
32
|
"prettier-plugin-organize-imports"
|
|
@@ -33,6 +37,7 @@
|
|
|
33
37
|
},
|
|
34
38
|
"devDependencies": {
|
|
35
39
|
"@types/node": "^25.6.0",
|
|
36
|
-
"prettier-plugin-organize-imports": "^4.3.0"
|
|
40
|
+
"prettier-plugin-organize-imports": "^4.3.0",
|
|
41
|
+
"vitest": "^4.1.5"
|
|
37
42
|
}
|
|
38
43
|
}
|
package/src/constants.ts
CHANGED
|
@@ -13,6 +13,11 @@ export const PROVIDER_NAME = "Llama.cpp";
|
|
|
13
13
|
*/
|
|
14
14
|
export const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The placeholder api-key if it couldn't be resolved
|
|
18
|
+
*/
|
|
19
|
+
export const API_KEY_PLACEHOLDER = "sk-placeholder";
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* The default context if the server didn't expose it
|
|
18
23
|
*/
|
package/src/interfaces/auth.ts
CHANGED
package/src/models/baseModel.ts
CHANGED
|
@@ -112,31 +112,26 @@ export abstract class BaseModel {
|
|
|
112
112
|
/**
|
|
113
113
|
* Unloads the model from llama-server
|
|
114
114
|
*/
|
|
115
|
-
|
|
116
115
|
async unload(): Promise<void> {
|
|
117
116
|
await rpc("/models/unload", { model: this.id });
|
|
118
117
|
}
|
|
119
118
|
|
|
120
119
|
/**
|
|
121
120
|
* Polls llama-server to check when the model is loaded
|
|
121
|
+
*
|
|
122
|
+
* @param startTime The initial polling timestamp
|
|
122
123
|
*/
|
|
123
|
-
async pollStatus(): Promise<void> {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (Date.now() - startTime > POLLING_TIMEOUT) {
|
|
131
|
-
const message = `Model loading timed out after ${POLLING_TIMEOUT} ms: ${this.id}`;
|
|
132
|
-
throw new Error(message);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
await new Promise((r) => setTimeout(r, POLLING_INTERVAL));
|
|
136
|
-
}
|
|
137
|
-
} catch (err) {
|
|
138
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
async pollStatus(startTime = Date.now()): Promise<void> {
|
|
125
|
+
const status = await this.getStatus();
|
|
126
|
+
if (status !== Status.LOADING) return;
|
|
127
|
+
|
|
128
|
+
// Force a timeout if we wasted too much time polling
|
|
129
|
+
if (Date.now() - startTime > POLLING_TIMEOUT) {
|
|
130
|
+
const message = `Model loading timed out after ${POLLING_TIMEOUT} ms: ${this.id}`;
|
|
139
131
|
throw new Error(message);
|
|
140
132
|
}
|
|
133
|
+
|
|
134
|
+
await new Promise((r) => setTimeout(r, POLLING_INTERVAL));
|
|
135
|
+
await this.pollStatus(startTime);
|
|
141
136
|
}
|
|
142
137
|
}
|
|
@@ -15,7 +15,7 @@ export class RouterModel extends BaseModel {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
get capabilities(): ["text"] | ["image"] {
|
|
18
|
-
const hasImage = this.model.status
|
|
18
|
+
const hasImage = this.model.status?.args?.includes("--mmproj") ?? false;
|
|
19
19
|
return hasImage ? ["image"] : ["text"];
|
|
20
20
|
}
|
|
21
21
|
|
package/src/tools/resolver.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { access, constants, readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
API_KEY_PLACEHOLDER,
|
|
5
|
+
DEFAULT_LLAMA_SERVER_URL,
|
|
6
|
+
PROVIDER_ID,
|
|
7
|
+
} from "../constants";
|
|
8
|
+
import { AuthFile } from "../interfaces/auth";
|
|
5
9
|
|
|
6
10
|
// The URL is detected once, to reuse forever
|
|
7
11
|
let resolvedUrl: string | undefined;
|
|
@@ -42,12 +46,12 @@ const readContents = async <T>(filePath: string): Promise<T | null> => {
|
|
|
42
46
|
* @param key Key to extract from the parsed JSON
|
|
43
47
|
* @returns The string value, or null if file/key missing or invalid
|
|
44
48
|
*/
|
|
45
|
-
const readConfigValue = async <T
|
|
49
|
+
const readConfigValue = async <T>(
|
|
46
50
|
filePath: string,
|
|
47
|
-
key:
|
|
48
|
-
): Promise<
|
|
51
|
+
key: keyof T,
|
|
52
|
+
): Promise<T[keyof T] | null> => {
|
|
49
53
|
const cfg = await readContents<T>(filePath);
|
|
50
|
-
return
|
|
54
|
+
return cfg?.[key] ?? null;
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
/**
|
|
@@ -55,16 +59,11 @@ const readConfigValue = async <T, U>(
|
|
|
55
59
|
* @returns The API key, as defined by the auth.json file
|
|
56
60
|
*/
|
|
57
61
|
export const resolveApiKey = async (): Promise<string> => {
|
|
58
|
-
const placeholder = "sk-placeholder";
|
|
59
|
-
|
|
60
62
|
const authPath = join(process.env.HOME || ".", ".pi", "agent", "auth.json");
|
|
61
|
-
if (!(await fileExists(authPath))) return
|
|
63
|
+
if (!(await fileExists(authPath))) return API_KEY_PLACEHOLDER;
|
|
62
64
|
|
|
63
|
-
const cfg = await readConfigValue<AuthFile,
|
|
64
|
-
|
|
65
|
-
PROVIDER_ID,
|
|
66
|
-
);
|
|
67
|
-
return cfg?.key ?? placeholder;
|
|
65
|
+
const cfg = await readConfigValue<AuthFile>(authPath, PROVIDER_ID);
|
|
66
|
+
return cfg?.key ?? API_KEY_PLACEHOLDER;
|
|
68
67
|
};
|
|
69
68
|
|
|
70
69
|
/**
|
|
@@ -81,10 +80,7 @@ const resolveGlobalUrl = async (): Promise<string | null> => {
|
|
|
81
80
|
|
|
82
81
|
if (!(await fileExists(globalPath))) return null;
|
|
83
82
|
|
|
84
|
-
return readConfigValue<Record<string, string
|
|
85
|
-
globalPath,
|
|
86
|
-
"llamaServerUrl",
|
|
87
|
-
);
|
|
83
|
+
return readConfigValue<Record<string, string>>(globalPath, "llamaServerUrl");
|
|
88
84
|
};
|
|
89
85
|
|
|
90
86
|
/**
|
|
@@ -96,7 +92,7 @@ const resolveProjectUrl = async (cwd: string): Promise<string | null> => {
|
|
|
96
92
|
const projectPath = join(cwd, ".pi", "llama-server.json");
|
|
97
93
|
|
|
98
94
|
if (!(await fileExists(projectPath))) return null;
|
|
99
|
-
return readConfigValue<Record<string, string
|
|
95
|
+
return readConfigValue<Record<string, string>>(projectPath, "url");
|
|
100
96
|
};
|
|
101
97
|
|
|
102
98
|
/**
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Action } from "../src/enums/action";
|
|
3
|
+
import { Mode } from "../src/enums/mode";
|
|
4
|
+
import { Status } from "../src/enums/status";
|
|
5
|
+
import { DataProperty } from "../src/interfaces/endpoints/models";
|
|
6
|
+
|
|
7
|
+
// Mock the retriever module before importing anything that depends on it
|
|
8
|
+
vi.mock("../src/tools/retriever", () => ({
|
|
9
|
+
rpc: vi.fn(),
|
|
10
|
+
isServerReady: vi.fn(),
|
|
11
|
+
listModels: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
class TestModel {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly model: DataProperty,
|
|
17
|
+
private readonly _mode: Mode,
|
|
18
|
+
private readonly _status: Status,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
get mode(): Mode {
|
|
22
|
+
return this._mode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get capabilities(): ["text"] | ["image"] {
|
|
26
|
+
return ["text"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getStatus(): Promise<Status> {
|
|
30
|
+
return this._status;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getContextSize(): Promise<number> {
|
|
34
|
+
return 4096;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const createModel = (
|
|
39
|
+
mode: Mode,
|
|
40
|
+
status: Status,
|
|
41
|
+
overrides: Partial<DataProperty> = {},
|
|
42
|
+
) =>
|
|
43
|
+
new TestModel(
|
|
44
|
+
{
|
|
45
|
+
id: "test",
|
|
46
|
+
tags: [],
|
|
47
|
+
object: "model",
|
|
48
|
+
owned_by: "test",
|
|
49
|
+
created: Date.now(),
|
|
50
|
+
...overrides,
|
|
51
|
+
},
|
|
52
|
+
mode,
|
|
53
|
+
status,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Replicates the getActionsForModel logic from handlers.ts for testing
|
|
58
|
+
* without needing the full Pi extension context.
|
|
59
|
+
*/
|
|
60
|
+
const getActionsForModel = async (model: TestModel): Promise<Array<Action>> => {
|
|
61
|
+
const routerModeActions: Record<Status, Array<Action>> = {
|
|
62
|
+
[Status.LOADED]: [Action.SWITCH, Action.UNLOAD, Action.INFO, Action.CANCEL],
|
|
63
|
+
[Status.LOADING]: [Action.INFO, Action.CANCEL],
|
|
64
|
+
[Status.FAILED]: [Action.RETRY, Action.CANCEL],
|
|
65
|
+
[Status.SLEEPING]: [Action.UNLOAD, Action.INFO, Action.CANCEL],
|
|
66
|
+
[Status.UNLOADED]: [Action.LOAD, Action.CANCEL],
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const singleModeActions: Record<Status, Array<Action>> = {
|
|
70
|
+
[Status.LOADED]: [Action.INFO, Action.CANCEL],
|
|
71
|
+
[Status.LOADING]: [Action.CANCEL],
|
|
72
|
+
[Status.FAILED]: [Action.CANCEL],
|
|
73
|
+
[Status.SLEEPING]: [Action.INFO, Action.CANCEL],
|
|
74
|
+
[Status.UNLOADED]: [Action.CANCEL],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const allActions =
|
|
78
|
+
model.mode === Mode.ROUTER ? routerModeActions : singleModeActions;
|
|
79
|
+
|
|
80
|
+
const status = await model.getStatus();
|
|
81
|
+
return allActions[status];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
describe("Action availability", () => {
|
|
85
|
+
const actionMatrix: Array<{
|
|
86
|
+
mode: Mode;
|
|
87
|
+
status: Status;
|
|
88
|
+
expected: Action[];
|
|
89
|
+
}> = [
|
|
90
|
+
// Router mode
|
|
91
|
+
{
|
|
92
|
+
mode: Mode.ROUTER,
|
|
93
|
+
status: Status.LOADED,
|
|
94
|
+
expected: [Action.SWITCH, Action.UNLOAD, Action.INFO, Action.CANCEL],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
mode: Mode.ROUTER,
|
|
98
|
+
status: Status.LOADING,
|
|
99
|
+
expected: [Action.INFO, Action.CANCEL],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
mode: Mode.ROUTER,
|
|
103
|
+
status: Status.FAILED,
|
|
104
|
+
expected: [Action.RETRY, Action.CANCEL],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
mode: Mode.ROUTER,
|
|
108
|
+
status: Status.SLEEPING,
|
|
109
|
+
expected: [Action.UNLOAD, Action.INFO, Action.CANCEL],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
mode: Mode.ROUTER,
|
|
113
|
+
status: Status.UNLOADED,
|
|
114
|
+
expected: [Action.LOAD, Action.CANCEL],
|
|
115
|
+
},
|
|
116
|
+
// Single mode
|
|
117
|
+
{
|
|
118
|
+
mode: Mode.SINGLE,
|
|
119
|
+
status: Status.LOADED,
|
|
120
|
+
expected: [Action.INFO, Action.CANCEL],
|
|
121
|
+
},
|
|
122
|
+
{ mode: Mode.SINGLE, status: Status.LOADING, expected: [Action.CANCEL] },
|
|
123
|
+
{ mode: Mode.SINGLE, status: Status.FAILED, expected: [Action.CANCEL] },
|
|
124
|
+
{
|
|
125
|
+
mode: Mode.SINGLE,
|
|
126
|
+
status: Status.SLEEPING,
|
|
127
|
+
expected: [Action.INFO, Action.CANCEL],
|
|
128
|
+
},
|
|
129
|
+
{ mode: Mode.SINGLE, status: Status.UNLOADED, expected: [Action.CANCEL] },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
it.each(actionMatrix)(
|
|
133
|
+
"should return correct actions for $mode/$status",
|
|
134
|
+
async ({ mode, status, expected }) => {
|
|
135
|
+
const model = createModel(mode, status);
|
|
136
|
+
const actions = await getActionsForModel(model);
|
|
137
|
+
expect(actions).toEqual(expected);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
it("should always include CANCEL regardless of mode or status", async () => {
|
|
142
|
+
for (const mode of [Mode.ROUTER, Mode.SINGLE]) {
|
|
143
|
+
for (const status of Object.values(Status)) {
|
|
144
|
+
const model = createModel(mode, status);
|
|
145
|
+
const actions = await getActionsForModel(model);
|
|
146
|
+
expect(actions).toContain(Action.CANCEL);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should not include mode-exclusive actions", async () => {
|
|
152
|
+
const singleLoaded = createModel(Mode.SINGLE, Status.LOADED);
|
|
153
|
+
expect(await getActionsForModel(singleLoaded)).not.toContain(Action.SWITCH);
|
|
154
|
+
expect(await getActionsForModel(singleLoaded)).not.toContain(Action.LOAD);
|
|
155
|
+
|
|
156
|
+
const singleFailed = createModel(Mode.SINGLE, Status.FAILED);
|
|
157
|
+
expect(await getActionsForModel(singleFailed)).not.toContain(Action.RETRY);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
API_KEY_PLACEHOLDER,
|
|
4
|
+
DEFAULT_LLAMA_SERVER_URL,
|
|
5
|
+
PROVIDER_ID,
|
|
6
|
+
} from "../src/constants";
|
|
7
|
+
|
|
8
|
+
describe("URL resolution fallback chain", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
delete process.env.LLAMA_SERVER_URL;
|
|
16
|
+
});
|
|
17
|
+
|
|
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
|
+
}));
|
|
24
|
+
|
|
25
|
+
const { resolveUrl } = await import("../src/tools/resolver");
|
|
26
|
+
const result = await resolveUrl("/tmp/test-project");
|
|
27
|
+
|
|
28
|
+
expect(result).toBe(DEFAULT_LLAMA_SERVER_URL);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
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
|
+
|
|
43
|
+
process.env.LLAMA_SERVER_URL = "http://env-url:8080";
|
|
44
|
+
|
|
45
|
+
const { resolveUrl } = await import("../src/tools/resolver");
|
|
46
|
+
const result = await resolveUrl("/tmp/test-project");
|
|
47
|
+
|
|
48
|
+
expect(result).toBe("http://localhost:9999");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
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
|
+
|
|
58
|
+
process.env.LLAMA_SERVER_URL = "http://env-url:8080";
|
|
59
|
+
|
|
60
|
+
const { resolveUrl } = await import("../src/tools/resolver");
|
|
61
|
+
const result = await resolveUrl("/tmp/test-project");
|
|
62
|
+
|
|
63
|
+
expect(result).toBe("http://env-url:8080");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
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");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
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");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("API key resolution", () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
vi.clearAllMocks();
|
|
108
|
+
vi.resetModules();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
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
|
+
}));
|
|
117
|
+
|
|
118
|
+
const { resolveApiKey } = await import("../src/tools/resolver");
|
|
119
|
+
const result = await resolveApiKey();
|
|
120
|
+
|
|
121
|
+
expect(result).toBe(API_KEY_PLACEHOLDER);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
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);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
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");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Mode } from "../src/enums/mode";
|
|
3
|
+
import { DataProperty } from "../src/interfaces/endpoints/models";
|
|
4
|
+
import { RouterModel } from "../src/models/routerModel";
|
|
5
|
+
|
|
6
|
+
// Helper to create a mock DataProperty
|
|
7
|
+
const createModel = (overrides: Partial<DataProperty> = {}): DataProperty => ({
|
|
8
|
+
id: "test-model",
|
|
9
|
+
aliases: ["test-alias"],
|
|
10
|
+
tags: [],
|
|
11
|
+
object: "model",
|
|
12
|
+
owned_by: "test",
|
|
13
|
+
created: Date.now(),
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("RouterModel context size extraction", () => {
|
|
18
|
+
it("should extract --ctx-size value", () => {
|
|
19
|
+
const model = new RouterModel(
|
|
20
|
+
createModel({
|
|
21
|
+
status: {
|
|
22
|
+
value: "loaded",
|
|
23
|
+
args: [
|
|
24
|
+
"--model",
|
|
25
|
+
"gguf",
|
|
26
|
+
"--ctx-size",
|
|
27
|
+
"4096",
|
|
28
|
+
"--batch-size",
|
|
29
|
+
"512",
|
|
30
|
+
],
|
|
31
|
+
preset: "default",
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Access the private method via any
|
|
37
|
+
const extractFrom = (model as any).extractFrom.bind(model);
|
|
38
|
+
expect(extractFrom("--ctx-size")).toBe(4096);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should extract --fit-ctx value when --ctx-size is not present", () => {
|
|
42
|
+
const model = new RouterModel(
|
|
43
|
+
createModel({
|
|
44
|
+
status: {
|
|
45
|
+
value: "loaded",
|
|
46
|
+
args: ["--model", "gguf", "--fit-ctx", "8192"],
|
|
47
|
+
preset: "default",
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const extractFrom = (model as any).extractFrom.bind(model);
|
|
53
|
+
expect(extractFrom("--fit-ctx")).toBe(8192);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should return null when argument is not found", () => {
|
|
57
|
+
const model = new RouterModel(
|
|
58
|
+
createModel({
|
|
59
|
+
status: {
|
|
60
|
+
value: "loaded",
|
|
61
|
+
args: ["--model", "gguf", "--batch-size", "512"],
|
|
62
|
+
preset: "default",
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const extractFrom = (model as any).extractFrom.bind(model);
|
|
68
|
+
expect(extractFrom("--ctx-size")).toBeNull();
|
|
69
|
+
expect(extractFrom("--fit-ctx")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should return null when argument has no following value", () => {
|
|
73
|
+
const model = new RouterModel(
|
|
74
|
+
createModel({
|
|
75
|
+
status: {
|
|
76
|
+
value: "loaded",
|
|
77
|
+
args: ["--model", "gguf", "--ctx-size"],
|
|
78
|
+
preset: "default",
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const extractFrom = (model as any).extractFrom.bind(model);
|
|
84
|
+
expect(extractFrom("--ctx-size")).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should return null when argument value is not a valid number", () => {
|
|
88
|
+
const model = new RouterModel(
|
|
89
|
+
createModel({
|
|
90
|
+
status: {
|
|
91
|
+
value: "loaded",
|
|
92
|
+
args: ["--model", "gguf", "--ctx-size", "not-a-number"],
|
|
93
|
+
preset: "default",
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const extractFrom = (model as any).extractFrom.bind(model);
|
|
99
|
+
expect(extractFrom("--ctx-size")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should prefer --ctx-size over --fit-ctx", async () => {
|
|
103
|
+
const model = new RouterModel(
|
|
104
|
+
createModel({
|
|
105
|
+
status: {
|
|
106
|
+
value: "loaded",
|
|
107
|
+
args: ["--model", "gguf", "--ctx-size", "4096", "--fit-ctx", "8192"],
|
|
108
|
+
preset: "default",
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const ctxSize = await model.getContextSize();
|
|
114
|
+
expect(ctxSize).toBe(4096);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should return DEFAULT_CTX when no context size args are present", async () => {
|
|
118
|
+
const { DEFAULT_CTX } = await import("../src/constants");
|
|
119
|
+
|
|
120
|
+
const model = new RouterModel(
|
|
121
|
+
createModel({
|
|
122
|
+
status: {
|
|
123
|
+
value: "loaded",
|
|
124
|
+
args: ["--model", "gguf"],
|
|
125
|
+
preset: "default",
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const ctxSize = await model.getContextSize();
|
|
131
|
+
expect(ctxSize).toBe(DEFAULT_CTX);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("RouterModel capabilities detection", () => {
|
|
136
|
+
it("should detect image capability when --mmproj is present", () => {
|
|
137
|
+
const model = new RouterModel(
|
|
138
|
+
createModel({
|
|
139
|
+
status: {
|
|
140
|
+
value: "loaded",
|
|
141
|
+
args: ["--model", "gguf", "--mmproj", "mmproj.gguf"],
|
|
142
|
+
preset: "default",
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(model.capabilities).toEqual(["image"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should detect text-only capability when --mmproj is absent", () => {
|
|
151
|
+
const model = new RouterModel(
|
|
152
|
+
createModel({
|
|
153
|
+
status: {
|
|
154
|
+
value: "loaded",
|
|
155
|
+
args: ["--model", "gguf"],
|
|
156
|
+
preset: "default",
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(model.capabilities).toEqual(["text"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should default to text when status is undefined", () => {
|
|
165
|
+
const model = new RouterModel(createModel({ status: undefined }));
|
|
166
|
+
|
|
167
|
+
expect(model.capabilities).toEqual(["text"]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("RouterModel mode", () => {
|
|
172
|
+
it("should always return ROUTER mode", () => {
|
|
173
|
+
const model = new RouterModel(createModel());
|
|
174
|
+
expect(model.mode).toBe(Mode.ROUTER);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { DEFAULT_CTX } from "../src/constants";
|
|
3
|
+
import { Mode } from "../src/enums/mode";
|
|
4
|
+
import { Status } from "../src/enums/status";
|
|
5
|
+
import { ModelProperty } from "../src/interfaces/endpoints/models";
|
|
6
|
+
import { SingleModel } from "../src/models/singleModel";
|
|
7
|
+
|
|
8
|
+
const mockRpc = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("../src/tools/retriever", () => ({
|
|
11
|
+
rpc: (...args: unknown[]) => mockRpc(...args),
|
|
12
|
+
isServerReady: vi.fn(),
|
|
13
|
+
listModels: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockRpc.mockClear();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const createModel = (extra: Partial<ModelProperty> = {}): SingleModel =>
|
|
21
|
+
new SingleModel(
|
|
22
|
+
{
|
|
23
|
+
id: "test",
|
|
24
|
+
tags: [],
|
|
25
|
+
object: "model",
|
|
26
|
+
owned_by: "test",
|
|
27
|
+
created: Date.now(),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "test",
|
|
31
|
+
model: "test.gguf",
|
|
32
|
+
modified_at: new Date().toISOString(),
|
|
33
|
+
size: "1B",
|
|
34
|
+
digest: "abc123",
|
|
35
|
+
type: "model",
|
|
36
|
+
description: "test",
|
|
37
|
+
tags: [],
|
|
38
|
+
capabilities: [],
|
|
39
|
+
parameters: "",
|
|
40
|
+
details: {
|
|
41
|
+
parent_model: "",
|
|
42
|
+
format: "",
|
|
43
|
+
family: "",
|
|
44
|
+
families: [],
|
|
45
|
+
parameter_size: "",
|
|
46
|
+
quantization_level: "",
|
|
47
|
+
},
|
|
48
|
+
...extra,
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
describe("SingleModel mode", () => {
|
|
53
|
+
it("should always return SINGLE mode", () => {
|
|
54
|
+
const model = createModel();
|
|
55
|
+
expect(model.mode).toBe(Mode.SINGLE);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("SingleModel capabilities", () => {
|
|
60
|
+
it("should detect image capability when multimodal", () => {
|
|
61
|
+
const model = createModel({ capabilities: ["multimodal"] });
|
|
62
|
+
expect(model.capabilities).toEqual(["image"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should detect text-only capability when not multimodal", () => {
|
|
66
|
+
const model = createModel({ capabilities: [] });
|
|
67
|
+
expect(model.capabilities).toEqual(["text"]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("SingleModel getStatus", () => {
|
|
72
|
+
it("should return LOADED when not sleeping", async () => {
|
|
73
|
+
mockRpc.mockResolvedValueOnce({ is_sleeping: false });
|
|
74
|
+
|
|
75
|
+
const model = createModel();
|
|
76
|
+
const status = await model.getStatus();
|
|
77
|
+
|
|
78
|
+
expect(status).toBe(Status.LOADED);
|
|
79
|
+
expect(mockRpc).toHaveBeenCalledWith("/props");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should return SLEEPING when is_sleeping is true", async () => {
|
|
83
|
+
mockRpc.mockResolvedValueOnce({ is_sleeping: true });
|
|
84
|
+
|
|
85
|
+
const model = createModel();
|
|
86
|
+
const status = await model.getStatus();
|
|
87
|
+
|
|
88
|
+
expect(status).toBe(Status.SLEEPING);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("SingleModel getContextSize", () => {
|
|
93
|
+
it("should return n_ctx from /slots endpoint", async () => {
|
|
94
|
+
mockRpc.mockResolvedValueOnce([{ n_ctx: 8192 }]);
|
|
95
|
+
|
|
96
|
+
const model = createModel();
|
|
97
|
+
const ctxSize = await model.getContextSize();
|
|
98
|
+
|
|
99
|
+
expect(ctxSize).toBe(8192);
|
|
100
|
+
expect(mockRpc).toHaveBeenCalledWith("/slots");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should cache the context size on first call", async () => {
|
|
104
|
+
mockRpc.mockResolvedValueOnce([{ n_ctx: 4096 }]);
|
|
105
|
+
|
|
106
|
+
const model = createModel();
|
|
107
|
+
const first = await model.getContextSize();
|
|
108
|
+
const second = await model.getContextSize();
|
|
109
|
+
|
|
110
|
+
expect(first).toBe(4096);
|
|
111
|
+
expect(second).toBe(4096);
|
|
112
|
+
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should return DEFAULT_CTX when /slots fails", async () => {
|
|
116
|
+
mockRpc.mockRejectedValueOnce(new Error("Connection refused"));
|
|
117
|
+
|
|
118
|
+
const model = createModel();
|
|
119
|
+
const ctxSize = await model.getContextSize();
|
|
120
|
+
|
|
121
|
+
expect(ctxSize).toBe(DEFAULT_CTX);
|
|
122
|
+
});
|
|
123
|
+
});
|