pi-llama-cpp 0.9.2 → 0.11.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 +163 -27
- package/package.json +4 -3
- package/src/api/client.ts +76 -32
- package/src/constants.ts +28 -3
- package/src/index.ts +4 -12
- package/src/interfaces/events.ts +14 -3
- package/src/interfaces/server.ts +20 -0
- package/src/interfaces/settings.ts +69 -0
- package/src/managers/command.ts +285 -43
- package/src/managers/events.ts +50 -12
- package/src/managers/server.ts +90 -17
- package/src/managers/settings.ts +268 -0
- package/src/models/baseModel.ts +32 -17
- package/src/models/legacyModel.ts +2 -2
- package/src/models/routerModel.ts +3 -3
- package/src/server.ts +86 -21
- package/src/sse/client.ts +28 -16
- package/src/sse/manager.ts +23 -10
- package/src/ui/serverListEditor.ts +481 -0
- package/src/utils/errors.ts +5 -0
- package/src/utils/settingsStore.ts +60 -0
- package/src/utils/urls.ts +16 -0
- package/tests/commandManager.test.ts +256 -11
- package/tests/events.test.ts +229 -64
- package/tests/mocks.ts +145 -32
- package/tests/server.test.ts +54 -6
- package/tests/serverListEditor.test.ts +637 -0
- package/tests/serverManager.test.ts +282 -39
- package/tests/settings.test.ts +793 -0
- package/tests/settingsStore.test.ts +209 -0
- package/tests/sseManager.test.ts +98 -0
- package/src/interfaces/auth.ts +0 -6
- package/src/resolver.ts +0 -149
- package/src/utils/cache.ts +0 -39
- package/src/utils/mutex.ts +0 -24
- package/tests/resolver.test.ts +0 -184
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { ApiKeyCredential, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
readStoredCredential,
|
|
4
|
+
SettingsManager,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
API_KEY_PLACEHOLDER,
|
|
8
|
+
AUTOLOAD_ON_MESSAGE,
|
|
9
|
+
LLAMA_SERVER_URL,
|
|
10
|
+
POLLING_TIMEOUT,
|
|
11
|
+
REACT_TO_MODEL_SELECT,
|
|
12
|
+
SERVER_TIMEOUT,
|
|
13
|
+
SETTINGS_KEY,
|
|
14
|
+
SORT_BY,
|
|
15
|
+
THINKING_BUDGETS,
|
|
16
|
+
type SortBy,
|
|
17
|
+
} from "../constants";
|
|
18
|
+
import { LlamaServer, LlamaSettings } from "../interfaces/settings";
|
|
19
|
+
import { Server } from "../server";
|
|
20
|
+
import { SettingsStore } from "../utils/settingsStore";
|
|
21
|
+
import { isValidServerUrl, normalizeUrl } from "../utils/urls";
|
|
22
|
+
|
|
23
|
+
export class LlamaSettingsManager {
|
|
24
|
+
private settingsManager = SettingsManager.create(process.cwd());
|
|
25
|
+
|
|
26
|
+
constructor(private readonly store: SettingsStore = new SettingsStore()) {}
|
|
27
|
+
|
|
28
|
+
/** Warnings collected during URL resolution (dropped invalid entries). */
|
|
29
|
+
private warnings: string[] = [];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns and clears warnings collected during URL resolution.
|
|
33
|
+
*/
|
|
34
|
+
takeWarnings(): string[] {
|
|
35
|
+
const warnings = [...this.warnings];
|
|
36
|
+
this.warnings.length = 0;
|
|
37
|
+
return warnings;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convenience getter for merged project/global settings
|
|
42
|
+
*/
|
|
43
|
+
private get mergedSettings(): Record<string, any> {
|
|
44
|
+
const merged = {
|
|
45
|
+
...this.settingsManager.getGlobalSettings(),
|
|
46
|
+
...this.settingsManager.getProjectSettings(),
|
|
47
|
+
} as Record<string, any>;
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convenience getter for the `llamaSettings` key
|
|
53
|
+
*/
|
|
54
|
+
private get llamaSettings(): LlamaSettings {
|
|
55
|
+
return this.mergedSettings[SETTINGS_KEY] ?? {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Convenience getter for the merged `servers` list (project overrides
|
|
60
|
+
* global, per-key merge)
|
|
61
|
+
*/
|
|
62
|
+
get llamaServers(): LlamaServer[] {
|
|
63
|
+
return this.llamaSettings.servers ?? [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the server URLs to use in the following order:
|
|
68
|
+
*
|
|
69
|
+
* - `LLAMA_SERVER_URL` env variable
|
|
70
|
+
* - `llamaSettings` key (current - project, then global)
|
|
71
|
+
* - `llamaServerUrl` key (legacy - project, then global)
|
|
72
|
+
* - Default URL
|
|
73
|
+
*
|
|
74
|
+
* @returns The list of URLs to use
|
|
75
|
+
*/
|
|
76
|
+
resolveUrls(): string[] {
|
|
77
|
+
let response = this.resolveEnvUrls();
|
|
78
|
+
if (response.length > 0) return response;
|
|
79
|
+
|
|
80
|
+
response = this.resolveServerUrls();
|
|
81
|
+
if (response.length > 0) return response;
|
|
82
|
+
|
|
83
|
+
response = this.resolveLegacyUrls();
|
|
84
|
+
if (response.length > 0) return response;
|
|
85
|
+
|
|
86
|
+
return [LLAMA_SERVER_URL];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolves the llama-server URLs from the environment variable.
|
|
91
|
+
*
|
|
92
|
+
* @returns A list of detected URLs
|
|
93
|
+
*/
|
|
94
|
+
private resolveEnvUrls(): string[] {
|
|
95
|
+
const raw = process.env.LLAMA_SERVER_URL;
|
|
96
|
+
if (!raw) return [];
|
|
97
|
+
|
|
98
|
+
return this.parseUrls(raw);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolves the llama-server URLs from `llamaSettings.servers`.
|
|
103
|
+
* Settings are merged, prioritizing project over global settings.
|
|
104
|
+
*
|
|
105
|
+
* @returns A list of detected URLs
|
|
106
|
+
*/
|
|
107
|
+
private resolveServerUrls(): string[] {
|
|
108
|
+
const { servers = [] } = this.llamaSettings;
|
|
109
|
+
return servers.map((s) => this.parseUrls(s.url)).flat();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolves the llama-server URLs from `llamaSettings.servers`.
|
|
114
|
+
* Settings are merged, prioritizing project over global settings.
|
|
115
|
+
*
|
|
116
|
+
* @returns A list of detected URLs
|
|
117
|
+
*/
|
|
118
|
+
private resolveLegacyUrls(): string[] {
|
|
119
|
+
const { llamaServerUrl = null } = this.mergedSettings;
|
|
120
|
+
if (!llamaServerUrl) return [];
|
|
121
|
+
|
|
122
|
+
return this.parseUrls(llamaServerUrl);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parses a raw URL string into an array of cleaned URLs.
|
|
127
|
+
* Splits on semicolons, trims whitespace, filters empty strings, strips
|
|
128
|
+
* trailing slashes, and drops entries without an http(s) scheme —
|
|
129
|
+
* collecting a warning for each dropped entry (same validation the
|
|
130
|
+
* `/models servers` editor applies).
|
|
131
|
+
*
|
|
132
|
+
* @returns A sanitized URL
|
|
133
|
+
*/
|
|
134
|
+
private parseUrls(raw: string): string[] {
|
|
135
|
+
return raw
|
|
136
|
+
.split(";")
|
|
137
|
+
.map(normalizeUrl)
|
|
138
|
+
.filter((u) => {
|
|
139
|
+
if (u.length === 0) return false;
|
|
140
|
+
if (!isValidServerUrl(u)) {
|
|
141
|
+
this.warnings.push(
|
|
142
|
+
`Ignoring invalid server URL '${u}' (needs http(s)://)`,
|
|
143
|
+
);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolves the servers that this extension will use.
|
|
152
|
+
* Uses `resolveUrls()` as the source of truth for URLs (env > settings >
|
|
153
|
+
* legacy > default), then applies `id`/`name` from `llamaSettings.servers`
|
|
154
|
+
* as overrides when available.
|
|
155
|
+
*
|
|
156
|
+
* @returns A list of Server objects
|
|
157
|
+
*/
|
|
158
|
+
resolveServers(): Server[] {
|
|
159
|
+
const urls = this.resolveUrls();
|
|
160
|
+
const serverConfigs = this.llamaSettings.servers ?? [];
|
|
161
|
+
|
|
162
|
+
return urls.map((url) => {
|
|
163
|
+
const config = serverConfigs.find((s) => s.url === url);
|
|
164
|
+
return new Server(this, {
|
|
165
|
+
baseUrl: url,
|
|
166
|
+
customId: config?.id,
|
|
167
|
+
customName: config?.name,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolves API key for the provider ID using Pi's stored credentials.
|
|
174
|
+
*
|
|
175
|
+
* @returns The API key to use for the provider
|
|
176
|
+
*/
|
|
177
|
+
resolveApiKey(providerId: string): string {
|
|
178
|
+
const credential = readStoredCredential(providerId) as ApiKeyCredential;
|
|
179
|
+
return credential?.key ?? API_KEY_PLACEHOLDER;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolves the current thinking level from Pi.
|
|
184
|
+
*
|
|
185
|
+
* @returns The thinking level
|
|
186
|
+
*/
|
|
187
|
+
resolveThinkingLevel(): ModelThinkingLevel | undefined {
|
|
188
|
+
return this.settingsManager.getDefaultThinkingLevel();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolves the effective thinking budgets from settings.
|
|
193
|
+
*
|
|
194
|
+
* @returns An object with selected budgets for thinking levels
|
|
195
|
+
*/
|
|
196
|
+
resolveThinkingBudgets(): Record<ModelThinkingLevel, number> {
|
|
197
|
+
const settingsBudgets = this.settingsManager.getThinkingBudgets() ?? {};
|
|
198
|
+
return {
|
|
199
|
+
...THINKING_BUDGETS,
|
|
200
|
+
...settingsBudgets,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Resolves whether the extension should react to model selection events.
|
|
206
|
+
*
|
|
207
|
+
* @returns `true` if the extension should load the model on model_select
|
|
208
|
+
*/
|
|
209
|
+
resolveReactToModelSelect(): boolean {
|
|
210
|
+
return this.llamaSettings.reactToModelSelect ?? REACT_TO_MODEL_SELECT;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolves whether the extension should auto-load models on message.
|
|
215
|
+
*
|
|
216
|
+
* @returns `true` if the extension should auto-load models
|
|
217
|
+
*/
|
|
218
|
+
resolveAutoloadOnMessage(): boolean {
|
|
219
|
+
return this.llamaSettings.autoloadOnMessage ?? AUTOLOAD_ON_MESSAGE;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolves the timeout settings for polling and server checks.
|
|
224
|
+
*
|
|
225
|
+
* @returns Object with polling and server timeout values
|
|
226
|
+
*/
|
|
227
|
+
resolveTimeouts(): { pollingTimeout: number; serverTimeout: number } {
|
|
228
|
+
return {
|
|
229
|
+
pollingTimeout: this.llamaSettings.pollingTimeout ?? POLLING_TIMEOUT,
|
|
230
|
+
serverTimeout: this.llamaSettings.serverTimeout ?? SERVER_TIMEOUT,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolves the sort order for model lists.
|
|
236
|
+
*
|
|
237
|
+
* @returns The sort order: "asc", "desc", "asc-name", "desc-name", or "api"
|
|
238
|
+
*/
|
|
239
|
+
resolveSortBy(): SortBy {
|
|
240
|
+
return this.llamaSettings.sortBy ?? SORT_BY;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Persists one llamaSettings field to the global settings file and
|
|
245
|
+
* reloads the in-memory settings so resolvers see the change immediately.
|
|
246
|
+
*
|
|
247
|
+
* Rejects if the file can't be read (e.g. invalid JSON) or written —
|
|
248
|
+
* in-memory state stays consistent (reload only on success).
|
|
249
|
+
*/
|
|
250
|
+
async setLlamaSetting<K extends keyof LlamaSettings>(
|
|
251
|
+
key: K,
|
|
252
|
+
value: LlamaSettings[K],
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
await this.store.updateKey(SETTINGS_KEY, (current) => {
|
|
255
|
+
const merged =
|
|
256
|
+
typeof current === "object" && current !== null
|
|
257
|
+
? (current as Record<string, unknown>)
|
|
258
|
+
: {};
|
|
259
|
+
return { ...merged, [key]: value };
|
|
260
|
+
});
|
|
261
|
+
await this.settingsManager.reload();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Shared singleton instance used across the extension.
|
|
267
|
+
*/
|
|
268
|
+
export const settings = new LlamaSettingsManager();
|
package/src/models/baseModel.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { FALLBACK_CTX, POLLING_INTERVAL } from "../constants";
|
|
3
3
|
import { Mode } from "../enums/mode";
|
|
4
4
|
import { Status } from "../enums/status";
|
|
5
5
|
import { DataProperty } from "../interfaces/endpoints/models";
|
|
@@ -16,14 +16,6 @@ export abstract class BaseModel {
|
|
|
16
16
|
protected readonly server: Server,
|
|
17
17
|
) {}
|
|
18
18
|
|
|
19
|
-
protected readonly statusMapper: Record<string, Status> = {
|
|
20
|
-
loaded: Status.LOADED,
|
|
21
|
-
loading: Status.LOADING,
|
|
22
|
-
failed: Status.FAILED,
|
|
23
|
-
sleeping: Status.SLEEPING,
|
|
24
|
-
unloaded: Status.UNLOADED,
|
|
25
|
-
};
|
|
26
|
-
|
|
27
19
|
protected readonly labelIcons: Record<Status, string> = {
|
|
28
20
|
[Status.LOADED]: "🟢",
|
|
29
21
|
[Status.LOADING]: "🟡",
|
|
@@ -130,9 +122,9 @@ export abstract class BaseModel {
|
|
|
130
122
|
const { data } = await this.server.fetchModels();
|
|
131
123
|
const { n_ctx } = data.find((m) => m.id === this.id)?.meta!;
|
|
132
124
|
|
|
133
|
-
return n_ctx ??
|
|
125
|
+
return n_ctx ?? FALLBACK_CTX;
|
|
134
126
|
} catch {
|
|
135
|
-
return
|
|
127
|
+
return FALLBACK_CTX;
|
|
136
128
|
}
|
|
137
129
|
}
|
|
138
130
|
|
|
@@ -202,11 +194,31 @@ export abstract class BaseModel {
|
|
|
202
194
|
await this.server.postRequest("load", this.id);
|
|
203
195
|
|
|
204
196
|
if (await this.server.sseManager.probeSSE()) {
|
|
205
|
-
|
|
206
|
-
|
|
197
|
+
try {
|
|
198
|
+
const { status, exit_code } =
|
|
199
|
+
await this.server.sseManager.subscribeToStatus(this.id);
|
|
207
200
|
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
if (status === "failed" || (status === "unloaded" && exit_code !== 0)) {
|
|
202
|
+
throw new Error(`Model loading failed: ${this.id}`);
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
// A real failed status from the server stays fatal.
|
|
206
|
+
if (
|
|
207
|
+
err instanceof Error &&
|
|
208
|
+
err.message.startsWith("Model loading failed")
|
|
209
|
+
) {
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
// An SSE timeout or connection failure is not proof the load failed:
|
|
213
|
+
// subscribeToStatus timers are only cleared by a terminal event on
|
|
214
|
+
// the same SSE connection, so a timer orphaned by a server restart
|
|
215
|
+
// or reconnect can fire during a later, healthy load. Check the
|
|
216
|
+
// model's real status over HTTP before surfacing an error.
|
|
217
|
+
await this.pollStatus();
|
|
218
|
+
const finalStatus = await this.getStatus();
|
|
219
|
+
if (finalStatus !== Status.LOADED && finalStatus !== Status.SLEEPING) {
|
|
220
|
+
throw new Error(`Model loading failed: ${this.id}`);
|
|
221
|
+
}
|
|
210
222
|
}
|
|
211
223
|
} else {
|
|
212
224
|
await this.pollStatus();
|
|
@@ -224,14 +236,17 @@ export abstract class BaseModel {
|
|
|
224
236
|
* Polls llama-server to check when the model is loaded
|
|
225
237
|
*
|
|
226
238
|
* @param startTime The initial polling timestamp
|
|
227
|
-
* @param timeout The maximum amount of ms before timeout. Defaults to
|
|
239
|
+
* @param timeout The maximum amount of ms before timeout. Defaults to server's pollingTimeout
|
|
228
240
|
* @param interval The polling interval. Defaults to POLLING_INTERVAL
|
|
229
241
|
*/
|
|
230
242
|
async pollStatus(
|
|
231
243
|
startTime: number = Date.now(),
|
|
232
|
-
timeout
|
|
244
|
+
timeout?: number,
|
|
233
245
|
interval: number = POLLING_INTERVAL,
|
|
234
246
|
): Promise<void> {
|
|
247
|
+
if (timeout === undefined) {
|
|
248
|
+
timeout = this.server.pollingTimeout;
|
|
249
|
+
}
|
|
235
250
|
while ((await this.getStatus()) === Status.LOADING) {
|
|
236
251
|
// Force a timeout if we wasted too much time polling
|
|
237
252
|
if (Date.now() - startTime > timeout) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FALLBACK_CTX } from "../constants";
|
|
2
2
|
import { Mode } from "../enums/mode";
|
|
3
3
|
import { SingleModel } from "./singleModel";
|
|
4
4
|
|
|
@@ -25,7 +25,7 @@ export class LegacyModel extends SingleModel {
|
|
|
25
25
|
const [{ max_model_len }] = data;
|
|
26
26
|
const contextSize = max_model_len === 0 ? n_ctx : max_model_len;
|
|
27
27
|
|
|
28
|
-
return contextSize ??
|
|
28
|
+
return contextSize ?? FALLBACK_CTX;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FALLBACK_CTX, POLLING_INTERVAL } from "../constants";
|
|
2
2
|
import { Mode } from "../enums/mode";
|
|
3
3
|
import { Status } from "../enums/status";
|
|
4
4
|
import { BaseModel } from "./baseModel";
|
|
@@ -41,7 +41,7 @@ export class RouterModel extends BaseModel {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const timeout =
|
|
44
|
+
const timeout = this.server.pollingTimeout - elapsed;
|
|
45
45
|
return await super.pollStatus(startTime, timeout);
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -60,7 +60,7 @@ export class RouterModel extends BaseModel {
|
|
|
60
60
|
const response =
|
|
61
61
|
this.extractFrom("--ctx-size") ??
|
|
62
62
|
this.extractFrom("--fit-ctx") ??
|
|
63
|
-
|
|
63
|
+
FALLBACK_CTX;
|
|
64
64
|
|
|
65
65
|
return response;
|
|
66
66
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { ApiClient } from "./api/client";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
API_KEY_PLACEHOLDER,
|
|
4
|
+
PROVIDER_NAME,
|
|
5
|
+
PROVIDER_PREFIX,
|
|
6
|
+
} from "./constants";
|
|
3
7
|
import { Mode } from "./enums/mode";
|
|
4
8
|
import { ServerStatus } from "./enums/serverStatus";
|
|
5
9
|
import { HealthEndpoint } from "./interfaces/endpoints/health";
|
|
@@ -8,20 +12,67 @@ import {
|
|
|
8
12
|
PropsEndpoint,
|
|
9
13
|
PropsModelEndpoint,
|
|
10
14
|
} from "./interfaces/endpoints/props";
|
|
15
|
+
import type { ServerOptions } from "./interfaces/server";
|
|
16
|
+
import type { LlamaSettingsManager } from "./managers/settings";
|
|
11
17
|
import { BaseModel } from "./models/baseModel";
|
|
12
18
|
import { LegacyModel } from "./models/legacyModel";
|
|
13
19
|
import { RouterModel } from "./models/routerModel";
|
|
14
20
|
import { SingleModel } from "./models/singleModel";
|
|
15
|
-
import { ConfigResolver } from "./resolver";
|
|
16
21
|
import { SSEManager } from "./sse/manager";
|
|
17
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Optional constructor collaborators for {@link Server} — the seam tests use
|
|
25
|
+
* to run the real Server against fake clients.
|
|
26
|
+
*
|
|
27
|
+
* Both are factories because their arguments only exist around construction:
|
|
28
|
+
* the API key is (re-)resolved by the Server, and SSEManager needs its owner.
|
|
29
|
+
* Factories must stay pure functions of their arguments — `initialize()`
|
|
30
|
+
* re-invokes both on every scan (the ApiClient rebuild picks up a fresh key,
|
|
31
|
+
* by design), so captured per-server state would leak across re-scans.
|
|
32
|
+
*/
|
|
33
|
+
export type ServerDeps = {
|
|
34
|
+
createApiClient?: (apiKey: string) => ApiClient;
|
|
35
|
+
createSSEManager?: (server: Server, apiKey: string) => SSEManager;
|
|
36
|
+
};
|
|
37
|
+
|
|
18
38
|
export class Server {
|
|
19
39
|
public readonly models: BaseModel[] = [];
|
|
20
|
-
private
|
|
21
|
-
private apiClient!: ApiClient;
|
|
40
|
+
private apiClient: ApiClient;
|
|
22
41
|
private sse!: SSEManager;
|
|
23
42
|
|
|
24
|
-
constructor(
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly settings: LlamaSettingsManager,
|
|
45
|
+
private readonly options: ServerOptions,
|
|
46
|
+
private readonly deps: ServerDeps = {},
|
|
47
|
+
) {
|
|
48
|
+
// Eager client: `isReady` may run before `initialize()` (health probing
|
|
49
|
+
// in ServerManager), so no lazy fallback is needed. initialize()
|
|
50
|
+
// rebuilds the client to re-resolve the API key.
|
|
51
|
+
this.apiClient =
|
|
52
|
+
deps.createApiClient?.(this.getApiKey()) ??
|
|
53
|
+
new ApiClient(options.baseUrl, this.getApiKey());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Base URL of this server endpoint. */
|
|
57
|
+
get baseUrl(): string {
|
|
58
|
+
return this.options.baseUrl;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Maximum time (ms) for server verification and SSE support probe.
|
|
63
|
+
* Resolved live from the injected settings manager.
|
|
64
|
+
*/
|
|
65
|
+
get serverTimeout(): number {
|
|
66
|
+
return this.settings.resolveTimeouts().serverTimeout;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Maximum time (ms) to wait for model loading before giving up.
|
|
71
|
+
* Resolved live from the injected settings manager.
|
|
72
|
+
*/
|
|
73
|
+
get pollingTimeout(): number {
|
|
74
|
+
return this.settings.resolveTimeouts().pollingTimeout;
|
|
75
|
+
}
|
|
25
76
|
|
|
26
77
|
/**
|
|
27
78
|
* Provides access to the SSE manager for direct subscriptions.
|
|
@@ -32,24 +83,37 @@ export class Server {
|
|
|
32
83
|
|
|
33
84
|
/**
|
|
34
85
|
* Generates a unique provider ID from a server URL.
|
|
86
|
+
* Uses custom ID if provided, otherwise falls back to URL-based ID.
|
|
35
87
|
*/
|
|
36
88
|
get providerId(): string {
|
|
37
|
-
return `${PROVIDER_PREFIX}=${this.baseUrl}`;
|
|
89
|
+
return this.options.customId ?? `${PROVIDER_PREFIX}=${this.baseUrl}`;
|
|
38
90
|
}
|
|
39
91
|
|
|
40
92
|
/**
|
|
41
93
|
* Generates a human-readable provider name from a server URL.
|
|
94
|
+
* Uses custom name as suffix if provided.
|
|
42
95
|
*/
|
|
43
96
|
get providerName(): string {
|
|
97
|
+
if (this.options.customName) {
|
|
98
|
+
return `${PROVIDER_NAME} (${this.options.customName})`;
|
|
99
|
+
}
|
|
44
100
|
return `${PROVIDER_NAME} (${this.baseUrl})`;
|
|
45
101
|
}
|
|
46
102
|
|
|
47
103
|
/**
|
|
48
|
-
* Retrieves the API key from the resolver
|
|
104
|
+
* Retrieves the API key from the config resolver.
|
|
105
|
+
* Tries custom ID first, then falls back to URL-based ID.
|
|
106
|
+
*
|
|
49
107
|
* @returns The API key
|
|
50
108
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
109
|
+
getApiKey(): string {
|
|
110
|
+
// Try custom ID first
|
|
111
|
+
if (this.options.customId) {
|
|
112
|
+
const key = this.settings.resolveApiKey(this.options.customId);
|
|
113
|
+
if (key !== API_KEY_PLACEHOLDER) return key;
|
|
114
|
+
}
|
|
115
|
+
// Fall back to URL-based ID
|
|
116
|
+
return this.settings.resolveApiKey(`${PROVIDER_PREFIX}=${this.baseUrl}`);
|
|
53
117
|
}
|
|
54
118
|
|
|
55
119
|
/**
|
|
@@ -57,11 +121,15 @@ export class Server {
|
|
|
57
121
|
* Clears the cache first so we always fetch fresh data.
|
|
58
122
|
*/
|
|
59
123
|
async initialize() {
|
|
60
|
-
const apiKey =
|
|
61
|
-
this.apiClient =
|
|
62
|
-
|
|
124
|
+
const apiKey = this.getApiKey();
|
|
125
|
+
this.apiClient =
|
|
126
|
+
this.deps.createApiClient?.(apiKey) ??
|
|
127
|
+
new ApiClient(this.baseUrl, apiKey);
|
|
128
|
+
this.sse =
|
|
129
|
+
this.deps.createSSEManager?.(this, apiKey) ??
|
|
130
|
+
new SSEManager(this, apiKey);
|
|
63
131
|
const { data } = await this.fetchModels();
|
|
64
|
-
const mode = await this.detectServerMode();
|
|
132
|
+
const mode = await this.detectServerMode(data);
|
|
65
133
|
|
|
66
134
|
// Setup models
|
|
67
135
|
const modelCtor = {
|
|
@@ -70,22 +138,21 @@ export class Server {
|
|
|
70
138
|
[Mode.SINGLE]: SingleModel,
|
|
71
139
|
}[mode];
|
|
72
140
|
|
|
73
|
-
const models: BaseModel[] = data
|
|
74
|
-
.map((m) => new modelCtor(m, this))
|
|
75
|
-
.sort((a, b) => (a.id > b.id ? 1 : a.id === b.id ? 0 : -1));
|
|
141
|
+
const models: BaseModel[] = data.map((m) => new modelCtor(m, this));
|
|
76
142
|
|
|
77
143
|
this.models.length = 0;
|
|
78
144
|
this.models.push(...models);
|
|
79
145
|
}
|
|
80
146
|
|
|
81
147
|
/**
|
|
82
|
-
* Detects the mode of the server
|
|
148
|
+
* Detects the mode of the server from the models data already fetched by
|
|
149
|
+
* {@link initialize} — no second /v1/models round-trip.
|
|
83
150
|
*
|
|
151
|
+
* @param data Models endpoint data fetched by initialize()
|
|
84
152
|
* @returns The detected mode
|
|
85
153
|
*/
|
|
86
|
-
private async detectServerMode(): Promise<Mode> {
|
|
154
|
+
private async detectServerMode(data: ModelsEndpoint["data"]): Promise<Mode> {
|
|
87
155
|
const { role } = await this.fetchServerProps();
|
|
88
|
-
const { data } = await this.fetchModels();
|
|
89
156
|
|
|
90
157
|
if (role === "router") return Mode.ROUTER;
|
|
91
158
|
if ("max_model_len" in data[0]) return Mode.LEGACY;
|
|
@@ -99,8 +166,6 @@ export class Server {
|
|
|
99
166
|
* @returns The server status
|
|
100
167
|
*/
|
|
101
168
|
async isReady(timeout: number): Promise<ServerStatus> {
|
|
102
|
-
this.apiClient ??= new ApiClient(this.baseUrl, await this.getApiKey());
|
|
103
|
-
|
|
104
169
|
try {
|
|
105
170
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
106
171
|
setTimeout(() => reject(new Error("timeout")), timeout),
|
package/src/sse/client.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { POLLING_INTERVAL } from "../constants";
|
|
2
2
|
import type { SSECallback, SSECleanup, SSEEvent } from "./types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Builds the full SSE endpoint URL, appending the API key as a query
|
|
6
|
+
* parameter when one is set. Shared by {@link SSEClient} and
|
|
7
|
+
* {@link SSEManager.probeSSE} so the two can't drift.
|
|
8
|
+
*/
|
|
9
|
+
export const buildSSEUrl = (endpoint: string, apiKey?: string): string => {
|
|
10
|
+
if (apiKey) {
|
|
11
|
+
return `${endpoint}?api_key=${encodeURIComponent(apiKey)}`;
|
|
12
|
+
}
|
|
13
|
+
return endpoint;
|
|
14
|
+
};
|
|
15
|
+
|
|
4
16
|
/**
|
|
5
17
|
* SSE client for llama-server's /models/sse endpoint.
|
|
6
18
|
*
|
|
@@ -13,6 +25,10 @@ export class SSEClient {
|
|
|
13
25
|
private subscribers: Map<string, SSECallback> = new Map();
|
|
14
26
|
private connected: boolean = false;
|
|
15
27
|
private reconnecting: boolean = false; // tracks if EventSource auto-reconnect is in progress
|
|
28
|
+
/**
|
|
29
|
+
* Single shared slot — each setOnConnectFailed call overwrites the
|
|
30
|
+
* previous callback (see there for the constraint this imposes).
|
|
31
|
+
*/
|
|
16
32
|
private _onConnectFailed: (() => void) | null = null;
|
|
17
33
|
private _hasReceivedEvents: boolean = false;
|
|
18
34
|
|
|
@@ -28,12 +44,16 @@ export class SSEClient {
|
|
|
28
44
|
/**
|
|
29
45
|
* Connects to the SSE endpoint.
|
|
30
46
|
*
|
|
47
|
+
* No current caller consumes the result: `subscribe()` triggers the
|
|
48
|
+
* connection without awaiting it, and connection failures before the
|
|
49
|
+
* first event are surfaced through the `setOnConnectFailed` callback.
|
|
50
|
+
*
|
|
31
51
|
* @returns true if the connection was established successfully
|
|
32
52
|
*/
|
|
33
53
|
async connect(): Promise<boolean> {
|
|
34
54
|
if (this.connected) return true;
|
|
35
55
|
|
|
36
|
-
const url = this.
|
|
56
|
+
const url = buildSSEUrl(this.sseEndpoint, this.apiKey);
|
|
37
57
|
|
|
38
58
|
try {
|
|
39
59
|
this.eventSource = new EventSource(url);
|
|
@@ -42,11 +62,6 @@ export class SSEClient {
|
|
|
42
62
|
return false;
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
this.eventSource.onopen = () => {
|
|
46
|
-
this.connected = true;
|
|
47
|
-
this.reconnecting = false;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
65
|
this.eventSource.onerror = () => {
|
|
51
66
|
// EventSource will auto-reconnect; we just track state
|
|
52
67
|
this.connected = false;
|
|
@@ -91,6 +106,13 @@ export class SSEClient {
|
|
|
91
106
|
* Sets a callback to be called when the connection fails before
|
|
92
107
|
* any event is received. Useful for rejecting promises early.
|
|
93
108
|
*
|
|
109
|
+
* Single shared slot: each call overwrites the previous callback, so at
|
|
110
|
+
* most one caller may depend on it at a time. The only caller today is
|
|
111
|
+
* `SSEManager.subscribeToStatus`, which must therefore not be invoked
|
|
112
|
+
* twice concurrently on the same client — the second registration would
|
|
113
|
+
* take over the failure signal and the first promise would only reject
|
|
114
|
+
* via its own timeout.
|
|
115
|
+
*
|
|
94
116
|
* @param callback - Called once when connection fails
|
|
95
117
|
*/
|
|
96
118
|
setOnConnectFailed(callback: () => void): void {
|
|
@@ -129,16 +151,6 @@ export class SSEClient {
|
|
|
129
151
|
this.subscribers.clear();
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
/**
|
|
133
|
-
* Builds the full URL with optional API key query param.
|
|
134
|
-
*/
|
|
135
|
-
private buildUrl(): string {
|
|
136
|
-
if (this.apiKey) {
|
|
137
|
-
return `${this.sseEndpoint}?api_key=${encodeURIComponent(this.apiKey)}`;
|
|
138
|
-
}
|
|
139
|
-
return this.sseEndpoint;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
154
|
/**
|
|
143
155
|
* Dispatches an SSE event to all matching subscribers.
|
|
144
156
|
*/
|