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.
@@ -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();
@@ -1,5 +1,5 @@
1
1
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
- import { DEFAULT_CTX, POLLING_INTERVAL, POLLING_TIMEOUT } from "../constants";
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 ?? DEFAULT_CTX;
125
+ return n_ctx ?? FALLBACK_CTX;
134
126
  } catch {
135
- return DEFAULT_CTX;
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
- const { status, exit_code } =
206
- await this.server.sseManager.subscribeToStatus(this.id);
197
+ try {
198
+ const { status, exit_code } =
199
+ await this.server.sseManager.subscribeToStatus(this.id);
207
200
 
208
- if (status === "failed" || (status === "unloaded" && exit_code !== 0)) {
209
- throw new Error(`Model loading failed: ${this.id}`);
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 POLLING_TIMEOUT
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: number = POLLING_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 { DEFAULT_CTX } from "../constants";
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 ?? DEFAULT_CTX;
28
+ return contextSize ?? FALLBACK_CTX;
29
29
  }
30
30
 
31
31
  /**
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CTX, POLLING_INTERVAL, POLLING_TIMEOUT } from "../constants";
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 = POLLING_TIMEOUT - elapsed;
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
- DEFAULT_CTX;
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 { PROVIDER_NAME, PROVIDER_PREFIX } from "./constants";
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 configResolver = new ConfigResolver();
21
- private apiClient!: ApiClient;
40
+ private apiClient: ApiClient;
22
41
  private sse!: SSEManager;
23
42
 
24
- constructor(readonly baseUrl: string) {}
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
- async getApiKey(): Promise<string> {
52
- return await this.configResolver.resolveApiKey(this.providerId);
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 = await this.getApiKey();
61
- this.apiClient = new ApiClient(this.baseUrl, apiKey);
62
- this.sse = new SSEManager(this.baseUrl, apiKey);
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.buildUrl();
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
  */