pi-llama-cpp 0.15.0 → 0.16.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/README.md +40 -3
- package/package.json +1 -1
- package/src/api/client.ts +59 -22
- package/src/enums/mode.ts +1 -0
- package/src/interfaces/endpoints/models.ts +6 -0
- package/src/interfaces/endpoints/props.ts +14 -0
- package/src/managers/command/models.ts +3 -4
- package/src/managers/settings.ts +9 -1
- package/src/models/legacyModel.ts +3 -3
- package/src/models/llamaSwapModel.ts +90 -0
- package/src/models/routerModel.ts +2 -2
- package/src/models/singleModel.ts +2 -2
- package/src/server.ts +36 -6
- package/src/ui/dialog/confirm.ts +1 -1
- package/src/ui/dialog/input.ts +1 -1
- package/src/ui/editors/override/entryEditor.ts +9 -9
- package/src/ui/editors/override/itemBuilder.ts +5 -2
- package/src/ui/editors/server/fields.ts +2 -2
- package/src/ui/editors/server/itemBuilder.ts +5 -2
- package/src/ui/editors/server/serverEditor.ts +7 -7
- package/src/utils/credentialResolver.ts +110 -0
- package/src/utils/health.ts +2 -3
- package/tests/mocks.ts +2 -0
- package/tests/models/llamaSwapModel.test.ts +327 -0
- package/tests/server/health.test.ts +2 -2
- package/tests/server/server.test.ts +52 -2
- package/tests/settings/settings.test.ts +54 -252
- package/tests/utils/credentialResolver.test.ts +66 -0
- package/tests/utils/urlResolver.test.ts +116 -0
|
@@ -74,7 +74,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
74
74
|
|
|
75
75
|
// -- abstract hooks -------------------------------------------------------
|
|
76
76
|
|
|
77
|
-
protected async buildSettingsList(): Promise<SettingsList> {
|
|
77
|
+
protected override async buildSettingsList(): Promise<SettingsList> {
|
|
78
78
|
const builder = new ServerItemBuilder(this.dialogs);
|
|
79
79
|
const serverTimeout = this.options.serverTimeout ?? SERVER_TIMEOUT;
|
|
80
80
|
// Run auth + health probes in parallel; ⛔ wins if auth fails,
|
|
@@ -131,7 +131,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
protected beginAdd(): void {
|
|
134
|
+
protected override beginAdd(): void {
|
|
135
135
|
const wizard = new ServerWizard(this.dialogs, (dialog) =>
|
|
136
136
|
this.openDialog(dialog),
|
|
137
137
|
);
|
|
@@ -144,7 +144,7 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
protected deleteSelected(): void {
|
|
147
|
+
protected override deleteSelected(): void {
|
|
148
148
|
const idx = this.selectedIndex;
|
|
149
149
|
const next = this.options.servers.filter((_, i) => i !== idx);
|
|
150
150
|
void this.persistSnapshot(next, () => {
|
|
@@ -152,17 +152,17 @@ export class ServerSettingsList extends ListEditor<ServerSettingsListOptions> {
|
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
protected readonly emptyHintKey = "emptyServers" as const;
|
|
155
|
+
protected override readonly emptyHintKey = "emptyServers" as const;
|
|
156
156
|
|
|
157
|
-
protected getRowId(index: number): string {
|
|
157
|
+
protected override getRowId(index: number): string {
|
|
158
158
|
return `server-${index}`;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
protected getRowLabel(index: number): string {
|
|
161
|
+
protected override getRowLabel(index: number): string {
|
|
162
162
|
return this.options.servers[index]?.url ?? "";
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
protected get deleteTitle(): string {
|
|
165
|
+
protected override get deleteTitle(): string {
|
|
166
166
|
return TITLES.deleteServer;
|
|
167
167
|
}
|
|
168
168
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { API_KEY_PLACEHOLDER } from "../constants";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves credential API keys from `auth.json` entries.
|
|
7
|
+
*
|
|
8
|
+
* Supports four formats:
|
|
9
|
+
* - **Literal** — `"sk-abc123"` used as-is
|
|
10
|
+
* - **Shell command** — `"!cat ~/.secrets/key"` executes and captures stdout
|
|
11
|
+
* - **Env ref** — `"$VAR"` or `"${VAR}"` resolved from `credential.env` then `process.env`
|
|
12
|
+
* - **Escape** — `"$$literal"` → `"$literal"`, `"$!bang"` → `"!bang"`
|
|
13
|
+
*/
|
|
14
|
+
export class CredentialResolver {
|
|
15
|
+
/**
|
|
16
|
+
* Resolves a credential key to its actual value.
|
|
17
|
+
*
|
|
18
|
+
* @param key - The raw key string from the credential
|
|
19
|
+
* @param env - Optional env map from `credential.env`. Checked before `process.env`.
|
|
20
|
+
* @returns The resolved API key value, or the placeholder on failure.
|
|
21
|
+
*/
|
|
22
|
+
resolve(key: string, env?: Record<string, string>): string {
|
|
23
|
+
if (!key) return API_KEY_PLACEHOLDER;
|
|
24
|
+
|
|
25
|
+
if (key.startsWith("!")) return this.resolveShellCommand(key);
|
|
26
|
+
if (key.startsWith("$$")) return this.resolveEscape(key);
|
|
27
|
+
if (key.startsWith("$!")) return this.resolveEscape(key);
|
|
28
|
+
if (!key.startsWith("$")) return key;
|
|
29
|
+
|
|
30
|
+
return this.resolveEnvRef(key, env) ?? API_KEY_PLACEHOLDER;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Executes a shell command and returns its trimmed stdout.
|
|
35
|
+
*
|
|
36
|
+
* Strips the leading `!` and runs the remainder as a shell command.
|
|
37
|
+
* On failure (non-zero exit or exception), returns the placeholder.
|
|
38
|
+
*
|
|
39
|
+
* @param command - The full key string starting with `!` (e.g. `"!cat ~/.secrets/key"`).
|
|
40
|
+
* @returns The trimmed stdout, or the API key placeholder on failure.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* resolveShellCommand("!echo my-secret") // → "my-secret"
|
|
45
|
+
* resolveShellCommand("!cat ~/.key") // → contents of file
|
|
46
|
+
* resolveShellCommand("!invalid/cmd") // → API_KEY_PLACEHOLDER
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
private resolveShellCommand(command: string): string {
|
|
50
|
+
try {
|
|
51
|
+
return (
|
|
52
|
+
execSync(command.slice(1), {
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
timeout: 10_000,
|
|
55
|
+
}).trim() || API_KEY_PLACEHOLDER
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
return API_KEY_PLACEHOLDER;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves escape sequences: `$$` → literal `$`, `$!` → literal `!`.
|
|
64
|
+
*
|
|
65
|
+
* Replaces the leading escape marker with the literal character and
|
|
66
|
+
* preserves any remaining text.
|
|
67
|
+
*
|
|
68
|
+
* @param key - A key string starting with `$$` or `$!`.
|
|
69
|
+
* @returns The literal character followed by the rest of the string.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* resolveEscape("$$literal") // → "$literal"
|
|
74
|
+
* resolveEscape("$!bang") // → "!bang"
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
private resolveEscape(key: string): string {
|
|
78
|
+
return key.charAt(1) + key.slice(2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolves `$VAR` or `${VAR}` syntax to the corresponding environment value.
|
|
83
|
+
*
|
|
84
|
+
* Matches the entire key against `$VAR` or `${VAR}` patterns. Looks up the
|
|
85
|
+
* variable first in the provided `env` map, then falls back to `process.env`.
|
|
86
|
+
*
|
|
87
|
+
* @param key - The key string containing a `$VAR` or `${VAR}` reference.
|
|
88
|
+
* @param env - Optional env map (e.g. from `credential.env`). Checked before `process.env`.
|
|
89
|
+
* @returns The resolved environment value, or `undefined` if the var is not found or the format is invalid.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* resolveEnvRef("$API_KEY", { API_KEY: "abc" }) // → "abc"
|
|
94
|
+
* resolveEnvRef("${API_KEY}", process.env) // → process.env.API_KEY
|
|
95
|
+
* resolveEnvRef("$UNSET") // → undefined
|
|
96
|
+
* resolveEnvRef("$invalid-var!") // → undefined (invalid name)
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
private resolveEnvRef(
|
|
100
|
+
key: string,
|
|
101
|
+
env?: Record<string, string>,
|
|
102
|
+
): string | undefined {
|
|
103
|
+
const match =
|
|
104
|
+
key.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/) ??
|
|
105
|
+
key.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/);
|
|
106
|
+
const varName = match?.[1];
|
|
107
|
+
if (!varName) return undefined;
|
|
108
|
+
return env?.[varName] ?? process.env[varName];
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/utils/health.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { ServerStatus } from "../enums/serverStatus";
|
|
2
|
-
import type { HealthEndpoint } from "../interfaces/endpoints/health";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Probes a llama-server's `/health` endpoint and classifies the outcome.
|
|
@@ -35,8 +34,8 @@ export const checkServerHealth = async (
|
|
|
35
34
|
signal: AbortSignal.timeout(timeout),
|
|
36
35
|
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
|
37
36
|
});
|
|
38
|
-
|
|
39
|
-
return
|
|
37
|
+
|
|
38
|
+
return response.ok ? ServerStatus.READY : ServerStatus.UNREACHABLE;
|
|
40
39
|
} catch (error) {
|
|
41
40
|
// `AbortSignal.timeout` rejects `fetch` with a `TimeoutError`
|
|
42
41
|
// DOMException (some runtimes surface it as `AbortError` with a
|
package/tests/mocks.ts
CHANGED
|
@@ -68,6 +68,8 @@ export const createFakeClients = (): {
|
|
|
68
68
|
get: (endpoint: string) => mockRpc(endpoint),
|
|
69
69
|
post: (endpoint: string, body?: Record<string, unknown>) =>
|
|
70
70
|
mockRpc(endpoint, body),
|
|
71
|
+
rawGet: (endpoint: string) => mockRpc(endpoint),
|
|
72
|
+
rawPost: (endpoint: string) => mockRpc(endpoint),
|
|
71
73
|
clearCache: vi.fn(),
|
|
72
74
|
} as unknown as ApiClient;
|
|
73
75
|
const sseManager = {
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Mode } from "../../src/enums/mode";
|
|
3
|
+
import { Status } from "../../src/enums/status";
|
|
4
|
+
import { LlamaSwapModel } from "../../src/models/llamaSwapModel";
|
|
5
|
+
import { createMockServer, mockRpc } from "../mocks";
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockRpc.mockReset();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const createModel = (
|
|
12
|
+
extra: Partial<Record<string, unknown>> = {},
|
|
13
|
+
serverOverrides: Parameters<typeof createMockServer>[0] = {},
|
|
14
|
+
): LlamaSwapModel =>
|
|
15
|
+
new LlamaSwapModel(
|
|
16
|
+
{
|
|
17
|
+
id: "test-model",
|
|
18
|
+
aliases: ["test-alias"],
|
|
19
|
+
tags: [],
|
|
20
|
+
object: "model",
|
|
21
|
+
owned_by: "test",
|
|
22
|
+
created: Date.now(),
|
|
23
|
+
...extra,
|
|
24
|
+
} as any,
|
|
25
|
+
createMockServer({ baseUrl: "http://127.0.0.1:8080", ...serverOverrides }),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe("LlamaSwapModel mode", () => {
|
|
29
|
+
it("should always return LLAMASWAP mode", () => {
|
|
30
|
+
const model = createModel();
|
|
31
|
+
expect(model.mode).toBe(Mode.LLAMASWAP);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("LlamaSwapModel capabilities", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
mockRpc.mockReset().mockResolvedValue({
|
|
38
|
+
data: [
|
|
39
|
+
{
|
|
40
|
+
id: "test-model",
|
|
41
|
+
architecture: {
|
|
42
|
+
input_modalities: ["text", "image"],
|
|
43
|
+
output_modalities: ["text"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should detect image capability when input_modalities includes image", async () => {
|
|
51
|
+
const model = createModel();
|
|
52
|
+
const { input } = await model.toProviderConfig();
|
|
53
|
+
|
|
54
|
+
expect(input).toEqual(["text", "image"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should detect text-only capability when input_modalities only has text", async () => {
|
|
58
|
+
mockRpc.mockResolvedValueOnce({
|
|
59
|
+
data: [
|
|
60
|
+
{
|
|
61
|
+
id: "test-model",
|
|
62
|
+
architecture: {
|
|
63
|
+
input_modalities: ["text"],
|
|
64
|
+
output_modalities: ["text"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const model = createModel();
|
|
71
|
+
const { input } = await model.toProviderConfig();
|
|
72
|
+
|
|
73
|
+
expect(input).toEqual(["text"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should return text-only when model is not found in fetchModels response", async () => {
|
|
77
|
+
mockRpc.mockResolvedValueOnce({
|
|
78
|
+
data: [
|
|
79
|
+
{
|
|
80
|
+
id: "other-model",
|
|
81
|
+
architecture: {
|
|
82
|
+
input_modalities: ["text", "image"],
|
|
83
|
+
output_modalities: ["text"],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const model = createModel();
|
|
90
|
+
const { input } = await model.toProviderConfig();
|
|
91
|
+
|
|
92
|
+
expect(input).toEqual(["text"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should return text-only when architecture is undefined", async () => {
|
|
96
|
+
mockRpc.mockResolvedValueOnce({
|
|
97
|
+
data: [
|
|
98
|
+
{
|
|
99
|
+
id: "test-model",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const model = createModel();
|
|
105
|
+
const { input } = await model.toProviderConfig();
|
|
106
|
+
|
|
107
|
+
expect(input).toEqual(["text"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("LlamaSwapModel overrides", () => {
|
|
112
|
+
it("should use contextSize override when set", async () => {
|
|
113
|
+
mockRpc.mockResolvedValueOnce({
|
|
114
|
+
data: [{ id: "test-model" }],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const model = createModel(
|
|
118
|
+
{},
|
|
119
|
+
{
|
|
120
|
+
overrides: { "test-model": { contextSize: 65536 } },
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const { contextWindow } = await model.toProviderConfig();
|
|
125
|
+
expect(contextWindow).toBe(65536);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should use capabilities override when set", async () => {
|
|
129
|
+
mockRpc.mockResolvedValueOnce({
|
|
130
|
+
data: [{ id: "test-model" }],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const model = createModel(
|
|
134
|
+
{},
|
|
135
|
+
{
|
|
136
|
+
overrides: { "test-model": { capabilities: ["text"] } },
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const { input } = await model.toProviderConfig();
|
|
141
|
+
expect(input).toEqual(["text"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should fall through to detection when no override matches", async () => {
|
|
145
|
+
const model = createModel(
|
|
146
|
+
{},
|
|
147
|
+
{
|
|
148
|
+
overrides: { "other-model": { contextSize: 65536 } },
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
mockRpc
|
|
153
|
+
.mockResolvedValueOnce({
|
|
154
|
+
data: [
|
|
155
|
+
{
|
|
156
|
+
id: "test-model",
|
|
157
|
+
architecture: {
|
|
158
|
+
input_modalities: ["text", "image"],
|
|
159
|
+
output_modalities: ["text"],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
})
|
|
164
|
+
.mockResolvedValueOnce({
|
|
165
|
+
data: [
|
|
166
|
+
{
|
|
167
|
+
id: "test-model",
|
|
168
|
+
architecture: {
|
|
169
|
+
input_modalities: ["text", "image"],
|
|
170
|
+
output_modalities: ["text"],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const { input } = await model.toProviderConfig();
|
|
177
|
+
expect(input).toEqual(["text", "image"]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("LlamaSwapModel status", () => {
|
|
182
|
+
it("should return LOADED when status.value is 'loaded'", async () => {
|
|
183
|
+
mockRpc.mockResolvedValueOnce({
|
|
184
|
+
data: [
|
|
185
|
+
{
|
|
186
|
+
id: "test-model",
|
|
187
|
+
status: {
|
|
188
|
+
value: "loaded",
|
|
189
|
+
args: [],
|
|
190
|
+
preset: "default",
|
|
191
|
+
failed: false,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const model = createModel();
|
|
198
|
+
const status = await model.getStatus();
|
|
199
|
+
|
|
200
|
+
expect(status).toBe(Status.LOADED);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should return UNLOADED when status.value is 'unloaded'", async () => {
|
|
204
|
+
mockRpc.mockResolvedValueOnce({
|
|
205
|
+
data: [
|
|
206
|
+
{
|
|
207
|
+
id: "test-model",
|
|
208
|
+
status: {
|
|
209
|
+
value: "unloaded",
|
|
210
|
+
args: [],
|
|
211
|
+
preset: "default",
|
|
212
|
+
failed: false,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const model = createModel();
|
|
219
|
+
const status = await model.getStatus();
|
|
220
|
+
|
|
221
|
+
expect(status).toBe(Status.UNLOADED);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should return UNLOADED when status.value is something other than 'loaded'", async () => {
|
|
225
|
+
mockRpc.mockResolvedValueOnce({
|
|
226
|
+
data: [
|
|
227
|
+
{
|
|
228
|
+
id: "test-model",
|
|
229
|
+
status: {
|
|
230
|
+
value: "loading",
|
|
231
|
+
args: [],
|
|
232
|
+
preset: "default",
|
|
233
|
+
failed: false,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const model = createModel();
|
|
240
|
+
const status = await model.getStatus();
|
|
241
|
+
|
|
242
|
+
expect(status).toBe(Status.UNLOADED);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("should return UNLOADED when model is not found in fetchModels response", async () => {
|
|
246
|
+
mockRpc.mockResolvedValueOnce({
|
|
247
|
+
data: [
|
|
248
|
+
{
|
|
249
|
+
id: "other-model",
|
|
250
|
+
status: {
|
|
251
|
+
value: "loaded",
|
|
252
|
+
args: [],
|
|
253
|
+
preset: "default",
|
|
254
|
+
failed: false,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const model = createModel();
|
|
261
|
+
const status = await model.getStatus();
|
|
262
|
+
|
|
263
|
+
expect(status).toBe(Status.UNLOADED);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should return UNLOADED when status is undefined", async () => {
|
|
267
|
+
mockRpc.mockResolvedValueOnce({
|
|
268
|
+
data: [
|
|
269
|
+
{
|
|
270
|
+
id: "test-model",
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const model = createModel();
|
|
276
|
+
const status = await model.getStatus();
|
|
277
|
+
|
|
278
|
+
expect(status).toBe(Status.UNLOADED);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("LlamaSwapModel load", () => {
|
|
283
|
+
it("should call GET to /upstream/{id} when model is not loaded", async () => {
|
|
284
|
+
const model = createModel();
|
|
285
|
+
// Override getStatus to return UNLOADED so load proceeds
|
|
286
|
+
model.getStatus = vi.fn().mockResolvedValue(Status.UNLOADED);
|
|
287
|
+
|
|
288
|
+
// mockRpc is used by ApiClient; load -> llamaSwapLoad -> apiClient.get
|
|
289
|
+
mockRpc.mockResolvedValue({});
|
|
290
|
+
|
|
291
|
+
await model.load();
|
|
292
|
+
|
|
293
|
+
expect(mockRpc).toHaveBeenCalledWith("/upstream/test-model");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("should throw when the GET request fails", async () => {
|
|
297
|
+
const model = createModel();
|
|
298
|
+
model.getStatus = vi.fn().mockResolvedValue(Status.UNLOADED);
|
|
299
|
+
|
|
300
|
+
mockRpc.mockRejectedValue(new Error("GET failed"));
|
|
301
|
+
|
|
302
|
+
await expect(model.load()).rejects.toThrow(
|
|
303
|
+
"Model loading failed: test-model",
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should not call fetch when model is already loaded", async () => {
|
|
308
|
+
const model = createModel();
|
|
309
|
+
model.getStatus = vi.fn().mockResolvedValue(Status.LOADED);
|
|
310
|
+
|
|
311
|
+
await model.load();
|
|
312
|
+
|
|
313
|
+
expect(mockRpc).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("LlamaSwapModel unload", () => {
|
|
318
|
+
it("should call POST to /api/models/unload/{id}", async () => {
|
|
319
|
+
const model = createModel();
|
|
320
|
+
|
|
321
|
+
mockRpc.mockResolvedValue({});
|
|
322
|
+
|
|
323
|
+
await model.unload();
|
|
324
|
+
|
|
325
|
+
expect(mockRpc).toHaveBeenCalledWith("/api/models/unload/test-model");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -10,7 +10,7 @@ const stubFetch = (
|
|
|
10
10
|
impl: (url: string, init?: RequestInit) => Promise<unknown>,
|
|
11
11
|
) => vi.stubGlobal("fetch", vi.fn(impl));
|
|
12
12
|
|
|
13
|
-
const okResponse = () => ({ json: async () => ({ status: "ok" }) });
|
|
13
|
+
const okResponse = () => ({ ok: true, json: async () => ({ status: "ok" }) });
|
|
14
14
|
|
|
15
15
|
const URL_ = "http://127.0.0.1:8080";
|
|
16
16
|
|
|
@@ -20,7 +20,7 @@ afterEach(() => {
|
|
|
20
20
|
|
|
21
21
|
describe("checkServerHealth", () => {
|
|
22
22
|
it("should return READY when the payload status is ok", async () => {
|
|
23
|
-
stubFetch(async () => ({ json: async () => ({ status: "ok" }) }));
|
|
23
|
+
stubFetch(async () => ({ ok: true, json: async () => ({ status: "ok" }) }));
|
|
24
24
|
|
|
25
25
|
expect(await checkServerHealth(URL_, 1000)).toBe(ServerStatus.READY);
|
|
26
26
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { POLLING_TIMEOUT, SERVER_TIMEOUT } from "../../src/constants";
|
|
3
|
+
import { Mode } from "../../src/enums/mode";
|
|
3
4
|
import { ServerStatus } from "../../src/enums/serverStatus";
|
|
5
|
+
import type { PropsEndpoint } from "../../src/interfaces/endpoints/props";
|
|
4
6
|
import type { LlamaSettingsManager } from "../../src/managers/settings";
|
|
5
7
|
import { Server } from "../../src/server";
|
|
6
8
|
import { createMockServer, makeSettingsStub, mockRpc } from "../mocks";
|
|
@@ -133,9 +135,57 @@ describe("Server fetchServerProps", () => {
|
|
|
133
135
|
const server = createMockServer();
|
|
134
136
|
const result = await server.fetchServerProps();
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
// Test mocks return a PropsEndpoint shape
|
|
139
|
+
expect((result as PropsEndpoint).role).toBe("router");
|
|
137
140
|
expect(mockRpc).toHaveBeenCalledWith("/props?autoload=false");
|
|
138
141
|
});
|
|
142
|
+
|
|
143
|
+
it("should return LlamaSwapPropsError shape for llama-swap", async () => {
|
|
144
|
+
mockRpc.mockResolvedValueOnce({
|
|
145
|
+
src: "llama-swap",
|
|
146
|
+
error: {
|
|
147
|
+
message: "no model id could be identified",
|
|
148
|
+
type: "invalid_request_error",
|
|
149
|
+
param: null,
|
|
150
|
+
code: "not_found",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const server = createMockServer();
|
|
155
|
+
const result = await server.fetchServerProps();
|
|
156
|
+
|
|
157
|
+
expect((result as { src: string }).src).toBe("llama-swap");
|
|
158
|
+
expect(mockRpc).toHaveBeenCalledWith("/props?autoload=false");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("Server detectServerMode", () => {
|
|
163
|
+
it("should detect LLAMASWAP mode when /props returns src", async () => {
|
|
164
|
+
// initialize() calls fetchModels() first, then fetchServerProps()
|
|
165
|
+
mockRpc
|
|
166
|
+
.mockResolvedValueOnce({
|
|
167
|
+
data: [{ id: "test-model" }],
|
|
168
|
+
object: "list",
|
|
169
|
+
})
|
|
170
|
+
.mockResolvedValueOnce({
|
|
171
|
+
src: "llama-swap",
|
|
172
|
+
error: {
|
|
173
|
+
message: "no model id could be identified",
|
|
174
|
+
type: "invalid_request_error",
|
|
175
|
+
param: null,
|
|
176
|
+
code: "not_found",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const server = createMockServer({
|
|
181
|
+
initialize: async () => {
|
|
182
|
+
await Server.prototype.initialize.call(server);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
await server.initialize();
|
|
186
|
+
|
|
187
|
+
expect(server.models[0].mode).toBe(Mode.LLAMASWAP);
|
|
188
|
+
});
|
|
139
189
|
});
|
|
140
190
|
|
|
141
191
|
describe("Server postRequest", () => {
|
|
@@ -214,7 +264,7 @@ describe("Server isReady", () => {
|
|
|
214
264
|
});
|
|
215
265
|
|
|
216
266
|
it("should return READY when health status is ok", async () => {
|
|
217
|
-
stubFetch(async () => ({ json: async () => ({ status: "ok" }) }));
|
|
267
|
+
stubFetch(async () => ({ ok: true, json: async () => ({ status: "ok" }) }));
|
|
218
268
|
|
|
219
269
|
const server = createMockServer();
|
|
220
270
|
const status = await server.isReady(1000);
|