dsh-magpie-connect 0.2.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/lib/index.js ADDED
@@ -0,0 +1,1638 @@
1
+ // src/index.ts
2
+ import { join as join4 } from "node:path";
3
+ import { writeFile as writeFile3 } from "node:fs/promises";
4
+
5
+ // src/adapter/catalog.ts
6
+ import { readFile, rename, rm, writeFile, mkdir } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ var KNOWN_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"];
9
+ var EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
10
+ var EFFORT_LABELS = {
11
+ none: { name: "None", description: "\u5173\u95ED\u601D\u8003\uFF0C\u76F4\u63A5\u56DE\u7B54" },
12
+ minimal: { name: "Minimal", description: "\u6700\u5C11\u601D\u8003\uFF0C\u6700\u5FEB" },
13
+ low: { name: "Low", description: "\u8F7B\u91CF\u601D\u8003\uFF0C\u9002\u5408\u7B80\u5355\u95EE\u7B54" },
14
+ medium: { name: "Medium", description: "\u9ED8\u8BA4\u6863\u4F4D\uFF0C\u5747\u8861\u8D28\u91CF\u4E0E\u901F\u5EA6" },
15
+ high: { name: "High", description: "\u6DF1\u5EA6\u601D\u8003\uFF0C\u9002\u5408\u590D\u6742\u7F16\u7801" },
16
+ xhigh: { name: "Extra high", description: "\u8D85\u9AD8\u601D\u8003\u5F3A\u5EA6" },
17
+ max: { name: "Max", description: "\u6700\u5F3A\u601D\u8003\uFF08pi-ai \u6863\u4F4D\uFF09\uFF0C\u6700\u6162" },
18
+ ultra: { name: "Ultra", description: "\u6781\u9650\u601D\u8003\uFF08\u7F51\u5173\u76F4\u901A\uFF09\uFF0C\u6700\u6162\u6700\u8D35" }
19
+ };
20
+ function normalizeEffort(raw) {
21
+ if (typeof raw !== "string") return void 0;
22
+ const level = raw.trim().toLowerCase();
23
+ return EFFORT_ORDER.includes(level) ? level : void 0;
24
+ }
25
+ function canonicalEfforts(values) {
26
+ if (!Array.isArray(values)) return void 0;
27
+ const out = [];
28
+ for (const value of values) {
29
+ const level = normalizeEffort(typeof value === "object" && value !== null ? value.effort ?? value : value);
30
+ if (level !== void 0 && !out.includes(level)) out.push(level);
31
+ }
32
+ if (out.length === 0) return void 0;
33
+ return EFFORT_ORDER.filter((level) => out.includes(level));
34
+ }
35
+ function effortsFor(efforts) {
36
+ const allowed = new Set(efforts ?? []);
37
+ return EFFORT_ORDER.filter((level) => allowed.has(level)).map((level) => {
38
+ const label = EFFORT_LABELS[level] ?? { name: level, description: "" };
39
+ return { id: level, name: label.name, description: label.description };
40
+ });
41
+ }
42
+ function thinkingLevelMapFor(efforts) {
43
+ const allowed = new Set(efforts ?? []);
44
+ const map = { off: null };
45
+ for (const level of KNOWN_EFFORTS) map[level] = allowed.has(level) ? level : null;
46
+ return map;
47
+ }
48
+ function requiresResponsesApiEntry(nativeEndpoints) {
49
+ const hasCompletions = nativeEndpoints.some((endpoint) => endpoint.includes("/chat/completions"));
50
+ if (hasCompletions) return false;
51
+ return nativeEndpoints.some((endpoint) => endpoint.includes("/responses"));
52
+ }
53
+ function modelApiForEntry(nativeEndpoints) {
54
+ return requiresResponsesApiEntry(nativeEndpoints) ? "openai-responses" : "openai-completions";
55
+ }
56
+ function decodeModel(raw) {
57
+ if (!raw || typeof raw !== "object") return void 0;
58
+ const record = raw;
59
+ const id = record.id;
60
+ if (typeof id !== "string" || id.length === 0) return void 0;
61
+ const num = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : void 0;
62
+ const displayName = typeof record.display_name === "string" && record.display_name || typeof record.magpie_label === "string" && record.magpie_label || id;
63
+ const contextWindow = num(record.context_window) ?? num(record.context_length) ?? num(record.max_input_tokens);
64
+ const maxTokens = num(record.max_output_tokens);
65
+ const modalities = record.modalities;
66
+ const inputModalities = Array.isArray(modalities?.input) ? modalities.input.map(String).map((s) => s.toLowerCase()) : [];
67
+ const image = inputModalities.includes("image");
68
+ const nativeEndpoints = Array.isArray(record.native_endpoints) ? record.native_endpoints.filter((e) => typeof e === "string") : [];
69
+ const reasoning = record.reasoning === true;
70
+ const levels = record.supported_reasoning_levels;
71
+ const efforts = Array.isArray(levels) ? canonicalEfforts(levels.map((entry) => entry?.effort ?? entry)) : void 0;
72
+ return { id, displayName, ...contextWindow !== void 0 ? { contextWindow } : {}, ...maxTokens !== void 0 ? { maxTokens } : {}, image, nativeEndpoints, reasoning, ...efforts !== void 0 ? { efforts } : {} };
73
+ }
74
+ function decodeMagpieModels(data) {
75
+ const result = /* @__PURE__ */ new Map();
76
+ if (!data || typeof data !== "object") return result;
77
+ const payload = data;
78
+ if (!Array.isArray(payload.data)) return result;
79
+ for (const raw of payload.data) {
80
+ const entry = decodeModel(raw);
81
+ if (entry && !result.has(entry.id)) result.set(entry.id, entry);
82
+ }
83
+ return result;
84
+ }
85
+ var staticMagpieModels = [
86
+ { id: "group/auto-gpt-4-1", displayName: "GPT-4.1", contextWindow: 1047576, maxTokens: 32768, image: true, nativeEndpoints: [], reasoning: false },
87
+ { id: "group/auto-gpt-6-luna", displayName: "GPT-6-Luna", contextWindow: 272e3, maxTokens: 128e3, image: true, nativeEndpoints: [], reasoning: true, efforts: ["low", "medium", "high", "xhigh", "max"] },
88
+ { id: "vercel/xiaomi/mimo-v2.6-flash", displayName: "xiaomi/mimo-v2.6-flash", contextWindow: 1048576, maxTokens: 131072, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: true },
89
+ { id: "vercel/alibaba/qwen3.8-omni-flash", displayName: "alibaba/qwen3.8-omni-flash", contextWindow: 1e6, maxTokens: 131072, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: true, efforts: ["none", "low", "medium", "xhigh"] },
90
+ { id: "vercel/openai/gpt-5.4-nano", displayName: "openai/gpt-5.4-nano", contextWindow: 272e3, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: true, efforts: ["none", "low", "medium", "high", "xhigh"] },
91
+ { id: "vercel/openai/gpt-4.1", displayName: "openai/gpt-4.1", contextWindow: 1047576, maxTokens: 32768, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: false },
92
+ { id: "vercel/google/gemini-2.5-flash-lite", displayName: "google/gemini-2.5-flash-lite", contextWindow: 1048576, maxTokens: 65536, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: true, efforts: ["low", "medium", "high"] },
93
+ { id: "opencode-zen/muse-spark-1.3-contributor-free", displayName: "Muse Spark 1.3 Free", contextWindow: 1048576, maxTokens: 131072, image: true, nativeEndpoints: ["/v1/responses"], reasoning: true, efforts: ["minimal", "low", "medium", "high", "xhigh"] },
94
+ { id: "opencode-zen/jev-1.13-free", displayName: "jev-1.13-free", image: false, nativeEndpoints: ["/v1/chat/completions", "/v1/responses", "/v1/messages"], reasoning: false },
95
+ { id: "opencode-zen/mimo-v2.6-flash-free", displayName: "MiMo-V2.6-Flash Free", contextWindow: 2e5, maxTokens: 32e3, image: true, nativeEndpoints: ["/v1/chat/completions", "/v1/responses", "/v1/messages"], reasoning: true },
96
+ { id: "opencode-zen/space-bunny-free", displayName: "Space Bunny Free", contextWindow: 524288, maxTokens: 524288, image: true, nativeEndpoints: ["/v1/chat/completions", "/v1/responses", "/v1/messages"], reasoning: true, efforts: ["low", "medium", "high", "xhigh", "max"] },
97
+ { id: "codex/gpt-6-luna", displayName: "GPT-6-Luna", contextWindow: 272e3, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/responses"], reasoning: true, efforts: ["low", "medium", "high", "xhigh", "max"] },
98
+ { id: "codex/gpt-5.6-terra", displayName: "GPT-5.6-Terra", contextWindow: 272e3, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/responses"], reasoning: true, efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] },
99
+ { id: "codex/gpt-5.6-luna", displayName: "GPT-5.6-Luna", contextWindow: 272e3, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/responses"], reasoning: true, efforts: ["low", "medium", "high", "xhigh", "max"] },
100
+ { id: "codex/gpt-5.5", displayName: "GPT-5.5", contextWindow: 272e3, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/responses"], reasoning: true, efforts: ["low", "medium", "high", "xhigh"] },
101
+ { id: "copilot/gemini-3.8-flash", displayName: "Gemini 3.8 Flash", contextWindow: 1048576, maxTokens: 65536, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: true, efforts: ["low", "medium", "high"] },
102
+ { id: "copilot/grok-4.7", displayName: "grok-4.7", contextWindow: 5e5, maxTokens: 5e5, image: true, nativeEndpoints: ["/v1/responses"], reasoning: true, efforts: ["low", "medium", "high", "xhigh"] },
103
+ { id: "copilot/gpt-4.1", displayName: "gpt-4.1", contextWindow: 1047576, maxTokens: 32768, image: true, nativeEndpoints: ["/v1/chat/completions", "/v1/responses", "/v1/messages"], reasoning: false },
104
+ { id: "copilot/gpt-6-luna", displayName: "gpt-6-luna", contextWindow: 922e3, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/responses"], reasoning: true, efforts: ["none", "low", "medium", "high", "xhigh", "max"] },
105
+ { id: "copilot/claude-sonnet-5.5", displayName: "claude-sonnet-5.5", contextWindow: 1e6, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/chat/completions", "/v1/messages"], reasoning: true, efforts: ["low", "medium", "high", "xhigh", "max"] },
106
+ { id: "workbuddy-ai/hy4-preview-f", displayName: "hy4-preview-f", contextWindow: 1e6, maxTokens: 64e3, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: true, efforts: ["high"] },
107
+ { id: "workbuddy-ai/deepseek-v4.1-flash", displayName: "deepseek-v4.1-flash", contextWindow: 1e6, maxTokens: 128e3, image: true, nativeEndpoints: ["/v1/chat/completions"], reasoning: true, efforts: ["low", "high", "max"] }
108
+ ];
109
+ var FETCH_TIMEOUT_MS = 3e4;
110
+ var ModelCatalog = class {
111
+ #entries = /* @__PURE__ */ new Map();
112
+ #hidden = /* @__PURE__ */ new Set();
113
+ #updatedAt = 0;
114
+ #lastError = "";
115
+ #refreshSeconds;
116
+ #cachePath;
117
+ #baseUrl;
118
+ #fetch;
119
+ #now;
120
+ #timer = null;
121
+ #stopped = false;
122
+ #onRefresh;
123
+ #onInvalidate;
124
+ #startupRetryMs;
125
+ /**
126
+ * Last announced exposed set. The picker caches its catalog read, so an
127
+ * invalidation is only worth a Host event when the exposed ids really
128
+ * moved — otherwise the refresh loop would emit every 5 minutes.
129
+ */
130
+ #announced = "";
131
+ constructor(options = {}) {
132
+ this.#refreshSeconds = options.refreshSeconds ?? 300;
133
+ this.#cachePath = options.cachePath;
134
+ this.#baseUrl = (options.baseUrl ?? "").replace(/\/+$/, "");
135
+ this.#fetch = options.fetchImpl ?? fetch;
136
+ this.#now = options.now ?? Date.now;
137
+ this.#onRefresh = options.onRefresh;
138
+ this.#onInvalidate = options.onInvalidate;
139
+ this.#startupRetryMs = options.startupRetryMs ?? 15e3;
140
+ }
141
+ /**
142
+ * Start the refresh loop: immediate fetch, fast retries while the live
143
+ * catalog is still empty (the first fetch often races the machine's
144
+ * network coming up), then the normal cadence.
145
+ */
146
+ async start() {
147
+ await this.refreshOnce();
148
+ let attempts = 0;
149
+ while (this.#entries.size === 0 && attempts < 4 && !this.#stopped) {
150
+ attempts += 1;
151
+ await new Promise((resolve) => setTimeout(resolve, this.#startupRetryMs));
152
+ if (this.#stopped) return;
153
+ await this.refreshOnce();
154
+ }
155
+ if (this.#stopped) return;
156
+ this.#timer = setInterval(() => {
157
+ void this.refreshOnce();
158
+ }, this.#refreshSeconds * 1e3);
159
+ this.#timer.unref?.();
160
+ }
161
+ stop() {
162
+ this.#stopped = true;
163
+ if (this.#timer) {
164
+ clearInterval(this.#timer);
165
+ this.#timer = null;
166
+ }
167
+ }
168
+ async refreshOnce() {
169
+ await this.refreshModels();
170
+ if (this.#onRefresh) {
171
+ try {
172
+ this.#onRefresh(this.snapshot(), this.#lastError);
173
+ } catch {
174
+ }
175
+ }
176
+ this.#announce();
177
+ }
178
+ /**
179
+ * Announce the exposed set when it changed.
180
+ *
181
+ * DSH's browser-side model directory caches one `modelCatalog()` read per
182
+ * Host generation and only re-reads it on `llm/adapters-updated`, so
183
+ * in-memory catalog mutations (hidden models, a refresh that moved the
184
+ * list) are otherwise invisible until restart. Firing the event on every
185
+ * refresh would also work but wakes the whole picker every 5 minutes, so
186
+ * the exposed ids are fingerprinted and only a real change is announced.
187
+ */
188
+ #announce() {
189
+ const fingerprint = this.#fingerprint();
190
+ if (fingerprint === this.#announced) return;
191
+ this.#announced = fingerprint;
192
+ if (!this.#onInvalidate) return;
193
+ try {
194
+ this.#onInvalidate();
195
+ } catch {
196
+ }
197
+ }
198
+ /** Stable fingerprint of the exposed set (ids + their capabilities). */
199
+ #fingerprint() {
200
+ const rows = this.list().map((id) => {
201
+ const entry = this.#entryFor(id);
202
+ return [
203
+ id,
204
+ entry?.displayName ?? "",
205
+ entry?.image === true ? "img" : "-",
206
+ entry?.reasoning === true ? "think" : "-",
207
+ (entry?.efforts ?? []).join(","),
208
+ String(entry?.contextWindow ?? 0),
209
+ String(entry?.maxTokens ?? 0)
210
+ ].join("|");
211
+ });
212
+ return rows.join("\n");
213
+ }
214
+ /** Whether a gateway API root is configured (empty baseUrl = not configured). */
215
+ get configured() {
216
+ return this.#baseUrl !== "";
217
+ }
218
+ async refreshModels() {
219
+ if (!this.configured) {
220
+ this.#lastError = "magpie gateway baseUrl is not configured \u2014 set it on the Magpie settings page";
221
+ return;
222
+ }
223
+ try {
224
+ const entries = await fetchMagpieModels(this.#baseUrl, this.#fetch);
225
+ this.#entries = entries;
226
+ this.#updatedAt = this.#now();
227
+ this.#lastError = "";
228
+ if (this.#cachePath) await saveModelsCache(this.#cachePath, entries, this.#now()).catch(() => {
229
+ });
230
+ } catch (err) {
231
+ if (this.#cachePath && this.#entries.size === 0) {
232
+ const cached = await loadModelsCache(this.#cachePath).catch(() => null);
233
+ if (cached && cached.size > 0) {
234
+ this.#entries = cached;
235
+ this.#updatedAt = this.#updatedAt === 0 ? this.#now() : this.#updatedAt;
236
+ return;
237
+ }
238
+ }
239
+ this.#lastError = err instanceof Error ? err.message : String(err);
240
+ }
241
+ }
242
+ getEntry(model) {
243
+ return this.#entries.get(model);
244
+ }
245
+ /**
246
+ * Replace the settings-page hidden set (models excluded from the picker).
247
+ * Announces: hiding/showing models changes the exposed list, and DSH's
248
+ * picker only re-reads it on `llm/adapters-updated`.
249
+ */
250
+ setHidden(ids) {
251
+ const next = new Set(ids);
252
+ if (next.size === this.#hidden.size && [...next].every((id) => this.#hidden.has(id))) return;
253
+ this.#hidden = next;
254
+ this.#announce();
255
+ }
256
+ /** Currently hidden model ids. */
257
+ hidden() {
258
+ return [...this.#hidden];
259
+ }
260
+ /**
261
+ * Drop hidden ids the gateway no longer serves.
262
+ *
263
+ * The hidden set is only meaningful against a live directory: keeping an id
264
+ * that has since left `GET /models` would make the settings page carry rows
265
+ * nobody can act on, and a model that later returns would come back
266
+ * invisibly hidden for no reason the user can see. Called on save, when the
267
+ * directory is the freshest the session has.
268
+ * @param ids - the hidden set as submitted.
269
+ * @returns the subset still worth storing.
270
+ */
271
+ pruneHidden(ids) {
272
+ if (!this.configured || this.#entries.size === 0) return [...ids];
273
+ return ids.filter((id) => this.#entries.has(id));
274
+ }
275
+ isHidden(model) {
276
+ return this.#hidden.has(model);
277
+ }
278
+ /** Point the refresh loop at another API root (settings page change). */
279
+ setBaseUrl(baseUrl) {
280
+ const root = baseUrl.replace(/\/+$/, "");
281
+ if (root === this.#baseUrl) return;
282
+ this.#baseUrl = root;
283
+ this.#entries = /* @__PURE__ */ new Map();
284
+ this.#updatedAt = 0;
285
+ this.#lastError = root === "" ? "magpie gateway baseUrl is not configured \u2014 set it on the Magpie settings page" : "";
286
+ this.#announce();
287
+ }
288
+ get baseUrl() {
289
+ return this.#baseUrl;
290
+ }
291
+ decision(model) {
292
+ if (!this.configured) return { allowed: false, source: "unconfigured", known: false };
293
+ if (this.#hidden.has(model)) return { allowed: false, source: "hidden_by_settings", known: true };
294
+ if (this.#entries.has(model)) return { allowed: true, source: "gateway", known: true };
295
+ const fallback = staticMagpieModels.find((entry) => entry.id === model);
296
+ if (fallback) return { allowed: true, source: "static_verified", known: false };
297
+ return { allowed: false, source: this.#entries.size === 0 ? "gateway_pending" : "gateway_unknown", known: false };
298
+ }
299
+ /** Entry lookup: live map first, static snapshot while configured (never unconfigured). */
300
+ #entryFor(model) {
301
+ if (!this.configured) return void 0;
302
+ return this.#entries.get(model) ?? staticMagpieModels.find((candidate) => candidate.id === model);
303
+ }
304
+ /**
305
+ * Reasoning capability for one model id (DSH `resolveModel().reasoning`
306
+ * + pi-ai `thinkingLevelMap` source of truth). Ladder-less thinking models
307
+ * (reasoning without an effort list) think with upstream defaults — see
308
+ * `thinks()` — and expose no picker.
309
+ */
310
+ reasoningFor(model) {
311
+ const entry = this.#entryFor(model);
312
+ const efforts = entry?.efforts;
313
+ if (!entry?.reasoning) return void 0;
314
+ if (efforts !== void 0 && efforts.length > 0) {
315
+ const mid = efforts[Math.floor(efforts.length / 2)] ?? "medium";
316
+ return { efforts: effortsFor(efforts), defaultEffort: efforts.includes("medium") ? "medium" : mid };
317
+ }
318
+ return void 0;
319
+ }
320
+ /** Whether the wire model may think (pi-ai `reasoning: true`). */
321
+ thinks(model) {
322
+ const entry = this.#entryFor(model);
323
+ return entry?.reasoning ?? false;
324
+ }
325
+ /** Whether the model accepts image input. */
326
+ supportsImage(model) {
327
+ const entry = this.#entryFor(model);
328
+ return entry?.image ?? false;
329
+ }
330
+ /** Whether one effort id is selectable for the model. */
331
+ supportsEffort(model, effort) {
332
+ const entry = this.#entryFor(model);
333
+ if (!entry?.reasoning) return false;
334
+ if (!entry.efforts || entry.efforts.length === 0) return true;
335
+ return entry.efforts.includes(effort);
336
+ }
337
+ requiresResponsesApi(model) {
338
+ const entry = this.#entryFor(model);
339
+ if (!entry) return /muse-spark|codex\/|grok/i.test(model);
340
+ return requiresResponsesApiEntry(entry.nativeEndpoints);
341
+ }
342
+ contextWindowFor(model) {
343
+ return this.#entryFor(model)?.contextWindow ?? 262144;
344
+ }
345
+ maxTokensFor(model) {
346
+ return this.#entryFor(model)?.maxTokens ?? 32768;
347
+ }
348
+ /** ids exposed to DSH: live gateway list minus hidden (empty while unconfigured). */
349
+ list() {
350
+ if (!this.configured) return [];
351
+ if (this.#entries.size === 0) return staticMagpieModels.map((entry) => entry.id).filter((id) => !this.#hidden.has(id));
352
+ return [...this.#entries.keys()].filter((id) => !this.#hidden.has(id)).sort();
353
+ }
354
+ /** Catalog health snapshot (pending/ready/stale + counts). */
355
+ snapshot() {
356
+ const age = this.#updatedAt === 0 ? Infinity : this.#now() - this.#updatedAt;
357
+ const stale = this.#updatedAt !== 0 && age > 10 * 60 * 1e3;
358
+ return {
359
+ status: this.#updatedAt === 0 ? "pending" : stale ? "stale" : "ready",
360
+ total: this.#entries.size,
361
+ exposed: this.list().length,
362
+ ...this.#updatedAt !== 0 ? { lastRefresh: new Date(this.#updatedAt).toISOString() } : {}
363
+ };
364
+ }
365
+ get lastError() {
366
+ return this.#lastError;
367
+ }
368
+ };
369
+ async function withTimeout(run, timeoutMs = FETCH_TIMEOUT_MS) {
370
+ const controller = new AbortController();
371
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
372
+ try {
373
+ return await run(controller.signal);
374
+ } catch (error) {
375
+ if (controller.signal.aborted) throw new Error(`models endpoint timed out after ${timeoutMs}ms`);
376
+ throw error;
377
+ } finally {
378
+ clearTimeout(timer);
379
+ }
380
+ }
381
+ async function fetchMagpieModels(baseUrl, fetchImpl, options = {}) {
382
+ const response = await withTimeout(
383
+ (signal) => fetchImpl(`${baseUrl.replace(/\/+$/, "")}/models`, {
384
+ headers: { accept: "application/json", ...options.headers ?? {} },
385
+ signal
386
+ }),
387
+ options.timeoutMs ?? FETCH_TIMEOUT_MS
388
+ );
389
+ if (!response.ok) throw new Error(`models endpoint returned HTTP ${response.status}`);
390
+ const payload = await response.json();
391
+ const models = decodeMagpieModels(payload);
392
+ if (models.size === 0) throw new Error("models endpoint returned an empty list");
393
+ return models;
394
+ }
395
+ async function saveModelsCache(path, entries, now) {
396
+ const cache = { updatedAt: now, entries: [...entries.values()] };
397
+ const tmp = `${path}.${process.pid}.tmp`;
398
+ await mkdir(dirname(path), { recursive: true });
399
+ await writeFile(tmp, JSON.stringify(cache), "utf8");
400
+ await rm(path, { force: true });
401
+ await rename(tmp, path);
402
+ }
403
+ async function loadModelsCache(path) {
404
+ const raw = JSON.parse(await readFile(path, "utf8"));
405
+ if (Date.now() - raw.updatedAt > 7 * 24 * 60 * 60 * 1e3) {
406
+ throw new Error("gateway cache too old");
407
+ }
408
+ return new Map(raw.entries.map((entry) => [entry.id, entry]));
409
+ }
410
+ function defaultCachePath(dataDir) {
411
+ return join(dataDir, "magpie-models.json");
412
+ }
413
+
414
+ // src/adapter/magpie-adapter.ts
415
+ import { createProvider } from "@earendil-works/pi-ai";
416
+ import * as openaiCompletions from "@earendil-works/pi-ai/api/openai-completions";
417
+ import * as openaiResponses from "@earendil-works/pi-ai/api/openai-responses";
418
+
419
+ // src/adapter/events.ts
420
+ var CONTEXT_WINDOW_EXCEEDED = "CONTEXT_WINDOW_EXCEEDED";
421
+ var EMPTY_RESPONSE = "EMPTY_RESPONSE";
422
+ var QUOTA_EXCEEDED = "QUOTA_EXCEEDED";
423
+ function classifyError(text) {
424
+ if (/\b(?:401|403)\b/.test(text)) return "AUTH";
425
+ if (/insufficient|quota|billing/i.test(text)) return QUOTA_EXCEEDED;
426
+ if (/\b429\b|rate.?limit/i.test(text)) return "RATE_LIMIT";
427
+ if (/\b413\b|payload too large|request body too large/i.test(text)) return "INVALID_REQUEST";
428
+ if (/\b400\b|invalid.?request/i.test(text)) return "INVALID_REQUEST";
429
+ if (/\b5\d\d\b/.test(text)) return "SERVER";
430
+ if (/\btime(?:d)?\s*out\b|timeout/i.test(text)) return "TIMEOUT";
431
+ if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b|terminated|premature close/i.test(text)) return "TRANSPORT";
432
+ return "UPSTREAM";
433
+ }
434
+ function isContextOverflow(message, contextWindow) {
435
+ return message.stopReason === "stop" && message.usage.input > contextWindow;
436
+ }
437
+ function mapStopReason(message, contextWindow) {
438
+ if (isContextOverflow(message, contextWindow) || message.stopReason === "error" && message.errorMessage !== void 0 && /context/i.test(message.errorMessage) && /exceed|window|length|token/i.test(message.errorMessage)) {
439
+ return {
440
+ kind: "error",
441
+ failure: {
442
+ message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
443
+ code: CONTEXT_WINDOW_EXCEEDED
444
+ }
445
+ };
446
+ }
447
+ switch (message.stopReason) {
448
+ case "stop":
449
+ if (message.content.length === 0) {
450
+ return {
451
+ kind: "error",
452
+ failure: { message: `model "${message.model}" returned a completed response with no content`, code: EMPTY_RESPONSE }
453
+ };
454
+ }
455
+ return { kind: "stop" };
456
+ case "length":
457
+ return { kind: "max-tokens" };
458
+ case "toolUse":
459
+ return { kind: "tool-calls" };
460
+ case "aborted":
461
+ return { kind: "aborted", failure: { message: message.errorMessage ?? "pi-ai stream aborted", code: "ABORTED" } };
462
+ case "error":
463
+ return {
464
+ kind: "error",
465
+ failure: { message: message.errorMessage ?? "pi-ai stream error", code: classifyError(message.errorMessage ?? "") }
466
+ };
467
+ }
468
+ }
469
+ function mapUsage(usage) {
470
+ return {
471
+ inputTokens: usage.input,
472
+ outputTokens: usage.output,
473
+ ...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
474
+ ...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {}
475
+ };
476
+ }
477
+ async function* toStreamChunks(events, contextWindow) {
478
+ const toolIds = /* @__PURE__ */ new Map();
479
+ for await (const event of events) {
480
+ switch (event.type) {
481
+ case "start":
482
+ break;
483
+ case "text_start":
484
+ yield { type: "block-start", index: event.contentIndex, blockType: "text" };
485
+ break;
486
+ case "text_delta":
487
+ yield { type: "text-delta", index: event.contentIndex, text: event.delta };
488
+ break;
489
+ case "text_end":
490
+ yield { type: "block-end", index: event.contentIndex, block: { type: "text", text: event.content } };
491
+ break;
492
+ case "thinking_start":
493
+ yield { type: "block-start", index: event.contentIndex, blockType: "reasoning" };
494
+ break;
495
+ case "thinking_delta":
496
+ yield { type: "reasoning-delta", index: event.contentIndex, text: event.delta };
497
+ break;
498
+ case "thinking_end":
499
+ yield { type: "block-end", index: event.contentIndex, block: { type: "reasoning", text: event.content } };
500
+ break;
501
+ case "toolcall_start": {
502
+ const partial = event.partial.content[event.contentIndex];
503
+ const id = partial?.type === "toolCall" ? partial.id ?? "" : "";
504
+ const name2 = partial?.type === "toolCall" ? partial.name ?? "" : "";
505
+ toolIds.set(event.contentIndex, { id, name: name2 });
506
+ yield { type: "block-start", index: event.contentIndex, blockType: "tool-call" };
507
+ break;
508
+ }
509
+ case "toolcall_delta": {
510
+ const known = toolIds.get(event.contentIndex);
511
+ yield {
512
+ type: "tool-call-delta",
513
+ index: event.contentIndex,
514
+ id: known?.id ?? "",
515
+ ...known?.name !== void 0 && known.name.length > 0 ? { name: known.name } : {},
516
+ argumentsDelta: event.delta
517
+ };
518
+ break;
519
+ }
520
+ case "toolcall_end":
521
+ yield {
522
+ type: "block-end",
523
+ index: event.contentIndex,
524
+ block: {
525
+ type: "tool-call",
526
+ id: event.toolCall.id,
527
+ name: event.toolCall.name,
528
+ arguments: JSON.stringify(event.toolCall.arguments)
529
+ }
530
+ };
531
+ break;
532
+ case "done":
533
+ yield { type: "usage", usage: mapUsage(event.message.usage) };
534
+ yield { type: "finish", reason: mapStopReason(event.message, contextWindow) };
535
+ return;
536
+ case "error":
537
+ yield { type: "usage", usage: mapUsage(event.error.usage) };
538
+ yield { type: "finish", reason: mapStopReason(event.error, contextWindow) };
539
+ return;
540
+ }
541
+ }
542
+ throw new Error("dsh-magpie-connect: pi-ai event stream ended without done/error");
543
+ }
544
+
545
+ // src/adapter/messages.ts
546
+ var REQUEST_MAX_PIXELS = 2048 * 2048;
547
+ var REQUEST_MAX_BYTES = 1024 * 1024;
548
+ function zeroUsage() {
549
+ return {
550
+ input: 0,
551
+ output: 0,
552
+ cacheRead: 0,
553
+ cacheWrite: 0,
554
+ totalTokens: 0,
555
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
556
+ };
557
+ }
558
+ function parseArguments(raw) {
559
+ if (typeof raw !== "string" || raw.length === 0) return {};
560
+ try {
561
+ const parsed = JSON.parse(raw);
562
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { value: parsed };
563
+ } catch {
564
+ return { raw };
565
+ }
566
+ }
567
+ function contentHasImage(messages) {
568
+ for (const message of messages) {
569
+ for (const block of message.content) {
570
+ if (block.type === "image" && block.offloaded !== true) return true;
571
+ }
572
+ }
573
+ return false;
574
+ }
575
+ function requestImageTarget(ref) {
576
+ const pixels = ref.width * ref.height;
577
+ if (!(pixels > REQUEST_MAX_PIXELS) || !(ref.width > 0 && ref.height > 0)) {
578
+ return { width: ref.width, height: ref.height, maxBytes: REQUEST_MAX_BYTES };
579
+ }
580
+ const scale = Math.sqrt(REQUEST_MAX_PIXELS / pixels);
581
+ return { width: Math.max(1, Math.floor(ref.width * scale)), height: Math.max(1, Math.floor(ref.height * scale)), maxBytes: REQUEST_MAX_BYTES };
582
+ }
583
+ function imageHandleText(ref, version) {
584
+ const name2 = ref.name ?? `attachment ${String(ref.attachmentId).slice(0, 12)}`;
585
+ return `Image ${name2}; request preview ${version.width}x${version.height}px.`;
586
+ }
587
+ function offloadedImageText(ref) {
588
+ const name2 = ref.name ?? `attachment ${String(ref.attachmentId).slice(0, 12)}`;
589
+ return `[image omitted (offloaded); ${name2}]`;
590
+ }
591
+ function toBase64(data) {
592
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64");
593
+ }
594
+ async function prepareRequestImages(messages, attachments, signal) {
595
+ const refs = /* @__PURE__ */ new Map();
596
+ for (const message of messages) {
597
+ for (const block of message.content) {
598
+ if (block.type !== "image") continue;
599
+ const image = block;
600
+ if (image.offloaded === true) continue;
601
+ if (!refs.has(image.attachment.attachmentId)) refs.set(image.attachment.attachmentId, image.attachment);
602
+ }
603
+ }
604
+ const versions = /* @__PURE__ */ new Map();
605
+ await Promise.all(
606
+ [...refs.values()].map(async (ref) => {
607
+ const prepared = await attachments.readImageRequest(ref, requestImageTarget(ref), signal);
608
+ versions.set(ref.attachmentId, {
609
+ data: prepared.data,
610
+ mediaType: prepared.mediaType,
611
+ bytes: prepared.bytes,
612
+ width: prepared.width,
613
+ height: prepared.height
614
+ });
615
+ })
616
+ );
617
+ return versions;
618
+ }
619
+ function userContentWithImages(blocks, requestImages) {
620
+ const content = [];
621
+ for (const block of blocks) {
622
+ switch (block.type) {
623
+ case "text":
624
+ if (block.text.length > 0) content.push({ type: "text", text: block.text });
625
+ break;
626
+ case "image": {
627
+ const image = block;
628
+ if (image.offloaded === true) {
629
+ content.push({ type: "text", text: offloadedImageText(image.attachment) });
630
+ break;
631
+ }
632
+ const version = requestImages.get(image.attachment.attachmentId);
633
+ if (!version) {
634
+ content.push({ type: "text", text: offloadedImageText(image.attachment) });
635
+ break;
636
+ }
637
+ content.push({ type: "text", text: imageHandleText(image.attachment, version) });
638
+ content.push({ type: "image", data: toBase64(version.data), mimeType: version.mediaType });
639
+ break;
640
+ }
641
+ case "file": {
642
+ const file = block;
643
+ content.push({ type: "text", text: `[file: ${file.attachment.name} (${file.attachment.bytes} bytes)]` });
644
+ break;
645
+ }
646
+ case "tool-result":
647
+ content.push({ type: "text", text: toolResultText(block.content) || "(no output)" });
648
+ break;
649
+ default:
650
+ break;
651
+ }
652
+ }
653
+ if (content.every((block) => block.type === "text")) return content.map((block) => block.text).join("");
654
+ return content;
655
+ }
656
+ function toPiAssistant(message, providerId, api = "openai-completions") {
657
+ const content = [];
658
+ for (const block of message.content) {
659
+ switch (block.type) {
660
+ case "text":
661
+ content.push({ type: "text", text: block.text });
662
+ break;
663
+ case "reasoning":
664
+ content.push({ type: "thinking", thinking: block.text });
665
+ break;
666
+ case "tool-call": {
667
+ const call = block;
668
+ content.push({ type: "toolCall", id: call.id, name: call.name, arguments: parseArguments(call.arguments) });
669
+ break;
670
+ }
671
+ case "image":
672
+ throw new Error("dsh-magpie-connect: assistant image output cannot be replayed to the model");
673
+ default:
674
+ break;
675
+ }
676
+ }
677
+ const source = message.source;
678
+ return {
679
+ role: "assistant",
680
+ content,
681
+ api,
682
+ provider: source?.kind === "model" && typeof source.provider === "string" ? source.provider : providerId,
683
+ model: source?.kind === "model" && typeof source.model === "string" ? source.model : providerId,
684
+ usage: zeroUsage(),
685
+ stopReason: content.some((block) => block.type === "toolCall") ? "toolUse" : "stop",
686
+ timestamp: 0
687
+ };
688
+ }
689
+ function flattenText(message) {
690
+ return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
691
+ }
692
+ function toolResultText(blocks) {
693
+ return blocks.map((block) => block.type === "text" ? block.text : block.type === "tool-result" ? toolResultText(block.content) : "").join("");
694
+ }
695
+ function toolsOf(options) {
696
+ const tools = options.tools?.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
697
+ return tools && tools.length > 0 ? tools : void 0;
698
+ }
699
+ function assistantApiFor(options, entryEndpoints) {
700
+ void options;
701
+ return modelApiForEntry(entryEndpoints);
702
+ }
703
+ function toPiContext(options, entryEndpoints = []) {
704
+ const providerId = options.provider;
705
+ const assistantApi = assistantApiFor(options, entryEndpoints);
706
+ const toolNames = /* @__PURE__ */ new Map();
707
+ const messages = [];
708
+ for (const message of options.messages) {
709
+ if (message.role === "system") {
710
+ const text2 = flattenText(message);
711
+ if (text2.length > 0) messages.push({ role: "user", content: text2, timestamp: 0 });
712
+ continue;
713
+ }
714
+ if (message.role === "assistant") {
715
+ const assistant = toPiAssistant(message, providerId, assistantApi);
716
+ for (const block of assistant.content) {
717
+ if (block.type === "toolCall") toolNames.set(block.id, block.name);
718
+ }
719
+ messages.push(assistant);
720
+ continue;
721
+ }
722
+ if (message.role === "developer") continue;
723
+ if (message.role === "tool") {
724
+ const text2 = flattenText(message);
725
+ messages.push({
726
+ role: "toolResult",
727
+ toolCallId: message.toolCallId ?? "",
728
+ toolName: message.toolCallId ? toolNames.get(message.toolCallId) ?? "unknown" : "unknown",
729
+ content: [{ type: "text", text: text2 || "(no output)" }],
730
+ isError: message.isError ?? false,
731
+ timestamp: 0
732
+ });
733
+ continue;
734
+ }
735
+ for (const block of message.content) {
736
+ if (block.type === "image") {
737
+ throw new Error("dsh-magpie-connect: image input requires the attachment service (use toPiContextWithImages)");
738
+ }
739
+ if (block.type === "file") {
740
+ continue;
741
+ }
742
+ }
743
+ const text = flattenText(message);
744
+ const fileText = message.content.filter((block) => block.type === "file").map((block) => `[file: ${block.attachment.name}]`).join("\n");
745
+ const combined = [text, fileText].filter((part) => part.length > 0).join("\n");
746
+ const results = message.content.filter((block) => block.type === "tool-result");
747
+ if (combined.length > 0 || results.length === 0) {
748
+ messages.push({ role: "user", content: combined, timestamp: 0 });
749
+ }
750
+ for (const result of results) {
751
+ messages.push({
752
+ role: "toolResult",
753
+ toolCallId: result.toolCallId,
754
+ toolName: toolNames.get(result.toolCallId) ?? "unknown",
755
+ content: [{ type: "text", text: toolResultText(result.content) || "(no output)" }],
756
+ isError: result.isError ?? false,
757
+ timestamp: 0
758
+ });
759
+ }
760
+ }
761
+ const context = { messages };
762
+ if (typeof options.system === "string" && options.system.length > 0) context.systemPrompt = options.system;
763
+ const tools = toolsOf(options);
764
+ if (tools) context.tools = tools;
765
+ return context;
766
+ }
767
+ async function toPiContextWithImages(options, attachments, entryEndpoints = []) {
768
+ const providerId = options.provider;
769
+ const assistantApi = assistantApiFor(options, entryEndpoints);
770
+ const requestImages = await prepareRequestImages(options.messages, attachments, options.signal);
771
+ const toolNames = /* @__PURE__ */ new Map();
772
+ const messages = [];
773
+ for (const message of options.messages) {
774
+ if (message.role === "system") {
775
+ const text = flattenText(message);
776
+ if (text.length > 0) messages.push({ role: "user", content: text, timestamp: 0 });
777
+ continue;
778
+ }
779
+ if (message.role === "assistant") {
780
+ const assistant = toPiAssistant(message, providerId, assistantApi);
781
+ for (const block of assistant.content) {
782
+ if (block.type === "toolCall") toolNames.set(block.id, block.name);
783
+ }
784
+ messages.push(assistant);
785
+ continue;
786
+ }
787
+ if (message.role === "developer") continue;
788
+ if (message.role === "tool") {
789
+ messages.push({
790
+ role: "toolResult",
791
+ toolCallId: message.toolCallId ?? "",
792
+ toolName: message.toolCallId ? toolNames.get(message.toolCallId) ?? "unknown" : "unknown",
793
+ content: userContentToBlocks(message.content, requestImages),
794
+ isError: message.isError ?? false,
795
+ timestamp: 0
796
+ });
797
+ continue;
798
+ }
799
+ const content = userContentWithImages(message.content, requestImages);
800
+ const results = message.content.filter((block) => block.type === "tool-result");
801
+ if (typeof content === "string" ? content.length > 0 || results.length === 0 : true) {
802
+ messages.push({ role: "user", content, timestamp: 0 });
803
+ } else if (typeof content === "string" && content.length === 0 && results.length === 0) {
804
+ messages.push({ role: "user", content, timestamp: 0 });
805
+ }
806
+ for (const result of results) {
807
+ messages.push({
808
+ role: "toolResult",
809
+ toolCallId: result.toolCallId,
810
+ toolName: toolNames.get(result.toolCallId) ?? "unknown",
811
+ content: userContentToBlocks(result.content, requestImages),
812
+ isError: result.isError ?? false,
813
+ timestamp: 0
814
+ });
815
+ }
816
+ }
817
+ const context = { messages };
818
+ if (typeof options.system === "string" && options.system.length > 0) context.systemPrompt = options.system;
819
+ const tools = toolsOf(options);
820
+ if (tools) context.tools = tools;
821
+ return context;
822
+ }
823
+ function userContentToBlocks(blocks, requestImages) {
824
+ const converted = userContentWithImages(blocks, requestImages);
825
+ if (typeof converted === "string") return [{ type: "text", text: converted || "(no output)" }];
826
+ return converted.length > 0 ? converted : [{ type: "text", text: "(no output)" }];
827
+ }
828
+
829
+ // src/adapter/watchdog.ts
830
+ var TIMED_OUT = Symbol("watchdog-timed-out");
831
+ function raceTimeout(promise, ms) {
832
+ let timer;
833
+ const timeout = new Promise((resolve) => {
834
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
835
+ const t = timer;
836
+ t.unref?.();
837
+ });
838
+ const cleanup = () => clearTimeout(timer);
839
+ return Promise.race([promise.then((value) => {
840
+ cleanup();
841
+ return value;
842
+ }, (err) => {
843
+ cleanup();
844
+ throw err;
845
+ }), timeout]);
846
+ }
847
+ function stallErrorEvent(model, waitMs, first) {
848
+ const phase = first ? "waiting for the first upstream event" : "waiting for the next upstream event";
849
+ return {
850
+ type: "error",
851
+ error: {
852
+ api: "openai-completions",
853
+ provider: "dsh-magpie-connect",
854
+ model,
855
+ content: [],
856
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
857
+ stopReason: "error",
858
+ // Must read as a timeout (events.ts classifies on /timeout/i -> TIMEOUT).
859
+ errorMessage: `upstream stream timeout: no upstream events for ${Math.round(waitMs / 1e3)}s while ${phase} (model "${model}") \u2014 the stalled stream was aborted`,
860
+ timestamp: Date.now()
861
+ }
862
+ };
863
+ }
864
+ async function* withStallTimeout(events, timeouts, options = {}) {
865
+ const iterator = events[Symbol.asyncIterator]();
866
+ try {
867
+ let first = true;
868
+ for (; ; ) {
869
+ const waitMs = first ? timeouts.firstEventTimeoutMs : timeouts.idleTimeoutMs;
870
+ if (!(waitMs > 0 && Number.isFinite(waitMs))) {
871
+ const next2 = await iterator.next();
872
+ if (next2.done) return;
873
+ first = false;
874
+ yield next2.value;
875
+ continue;
876
+ }
877
+ const next = await raceTimeout(iterator.next(), waitMs);
878
+ if (next === TIMED_OUT) {
879
+ try {
880
+ options.onTimeout?.();
881
+ } catch {
882
+ }
883
+ yield stallErrorEvent(options.model ?? "unknown", waitMs, first);
884
+ return;
885
+ }
886
+ if (next.done) return;
887
+ first = false;
888
+ yield next.value;
889
+ }
890
+ } finally {
891
+ void Promise.resolve(iterator.return?.()).catch(() => {
892
+ });
893
+ }
894
+ }
895
+
896
+ // src/adapter/magpie-adapter.ts
897
+ var PROVIDER_ID = "dsh-magpie-connect";
898
+ var DEFAULT_DISPLAY_NAME = "magpie";
899
+ var DEFAULT_CONTEXT_WINDOW = 262144;
900
+ var DEFAULT_MAX_TOKENS = 32768;
901
+ var DEFAULT_API_KEY = "not-needed";
902
+ var DEFAULT_MAX_RETRIES = 2;
903
+ var DEFAULT_TIMEOUT_MS = 3e5;
904
+ var DEFAULT_FIRST_EVENT_TIMEOUT_MS = 9e4;
905
+ var DEFAULT_IDLE_TIMEOUT_MS = 6e4;
906
+ var STRENGTH_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
907
+ function planReasoningEffort(requested, model, catalog) {
908
+ if (typeof requested !== "string") return void 0;
909
+ const level = requested.trim().toLowerCase();
910
+ if (level === "") return void 0;
911
+ const thinks = catalog.thinks?.(model) ?? false;
912
+ if (!thinks) return void 0;
913
+ const normalized = level === "off" ? "none" : level;
914
+ const supports = (effort) => catalog.supportsEffort?.(model, effort) ?? false;
915
+ const ladder = catalog.reasoningFor?.(model)?.efforts.map((effort) => effort.id);
916
+ const ladderLess = ladder === void 0;
917
+ if (normalized === "none") {
918
+ return supports("none") ? "none" : void 0;
919
+ }
920
+ if (!STRENGTH_ORDER.includes(normalized)) return void 0;
921
+ if (supports(normalized) || ladderLess) return normalized;
922
+ const order = STRENGTH_ORDER;
923
+ const index = order.indexOf(normalized);
924
+ if (index !== -1) {
925
+ for (let i = index + 1; i < order.length; i++) {
926
+ const candidate = order[i];
927
+ if (supports(candidate)) return candidate;
928
+ }
929
+ for (let i = index - 1; i >= 0; i--) {
930
+ const candidate = order[i];
931
+ if (supports(candidate)) return candidate;
932
+ }
933
+ }
934
+ return void 0;
935
+ }
936
+ function toPiModel(providerId, baseUrl, id, catalog, contextWindow = DEFAULT_CONTEXT_WINDOW, maxTokens = DEFAULT_MAX_TOKENS) {
937
+ const endpoints = catalog.getEntry?.(id)?.nativeEndpoints ?? [];
938
+ const api = modelApiForEntry(endpoints);
939
+ const entryThinks = catalog.thinks?.(id) ?? api === "openai-responses";
940
+ const ladder = catalog.reasoningFor?.(id);
941
+ const ladderIds = ladder?.efforts.map((effort) => effort.id);
942
+ const resolvedContext = catalog.contextWindowFor?.(id) ?? contextWindow;
943
+ const resolvedMax = catalog.maxTokensFor?.(id) ?? maxTokens;
944
+ if (api === "openai-responses") {
945
+ return {
946
+ id,
947
+ name: id,
948
+ api,
949
+ provider: providerId,
950
+ baseUrl,
951
+ reasoning: true,
952
+ thinkingLevelMap: thinkingLevelMapFor(ladderIds),
953
+ input: catalog.supportsImage?.(id) ?? true ? ["text", "image"] : ["text"],
954
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
955
+ contextWindow: resolvedContext,
956
+ maxTokens: resolvedMax
957
+ };
958
+ }
959
+ const wire = {
960
+ id,
961
+ name: id,
962
+ api,
963
+ provider: providerId,
964
+ baseUrl,
965
+ reasoning: entryThinks,
966
+ input: catalog.supportsImage?.(id) ?? false ? ["text", "image"] : ["text"],
967
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
968
+ contextWindow: resolvedContext,
969
+ maxTokens: resolvedMax
970
+ };
971
+ if (entryThinks) wire.thinkingLevelMap = thinkingLevelMapFor(ladderIds);
972
+ return wire;
973
+ }
974
+ var MagpieAdapter = class {
975
+ #catalog;
976
+ #provider;
977
+ #providerId;
978
+ #displayName;
979
+ /** Static fallback API root; the live settings page overrides per request. */
980
+ #fallbackBaseUrl;
981
+ #fallbackApiKey;
982
+ #runtime;
983
+ #maxRetries;
984
+ #timeoutMs;
985
+ #firstEventTimeoutMs;
986
+ #idleTimeoutMs;
987
+ #resolveAttachments;
988
+ constructor(catalog, options = {}) {
989
+ this.#catalog = catalog;
990
+ this.#providerId = options.providerId ?? PROVIDER_ID;
991
+ this.#displayName = options.displayName ?? (options.providerId ?? DEFAULT_DISPLAY_NAME);
992
+ this.#fallbackBaseUrl = (options.magpieBaseUrl ?? options.baseUrl ?? "").replace(/\/+$/, "");
993
+ this.#fallbackApiKey = options.apiKey ?? DEFAULT_API_KEY;
994
+ this.#runtime = options.runtime;
995
+ this.#maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
996
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
997
+ this.#firstEventTimeoutMs = options.firstEventTimeoutMs ?? DEFAULT_FIRST_EVENT_TIMEOUT_MS;
998
+ this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
999
+ this.#resolveAttachments = options.resolveAttachments;
1000
+ if (options.providerOverride !== void 0) {
1001
+ this.#provider = options.providerOverride;
1002
+ return;
1003
+ }
1004
+ this.#provider = createProvider({
1005
+ id: this.#providerId,
1006
+ name: this.#displayName,
1007
+ baseUrl: this.#fallbackBaseUrl,
1008
+ auth: {
1009
+ apiKey: {
1010
+ name: "Magpie LAN gateway",
1011
+ resolve: async () => ({ auth: { apiKey: this.#runtime?.apiKey() ?? this.#fallbackApiKey } })
1012
+ }
1013
+ },
1014
+ models: [],
1015
+ // Mixed-API provider: pi-ai dispatches on `model.api`, so completions
1016
+ // models ride /v1/chat/completions while responses-only models ride
1017
+ // /v1/responses through the same provider instance.
1018
+ api: {
1019
+ "openai-completions": openaiCompletions,
1020
+ "openai-responses": openaiResponses
1021
+ }
1022
+ });
1023
+ }
1024
+ providerInfo(provider) {
1025
+ return { id: provider, name: this.#displayName };
1026
+ }
1027
+ /**
1028
+ * dsh-llm calls this unconditionally at registration.
1029
+ * undefined = the host default retry policy.
1030
+ */
1031
+ providerRetryPolicy(_provider) {
1032
+ return void 0;
1033
+ }
1034
+ /** Advisory catalog for the DSH model picker (deduped; dsh-llm rejects duplicates). */
1035
+ listModels(provider) {
1036
+ const seen = /* @__PURE__ */ new Set();
1037
+ const models = [];
1038
+ for (const id of this.#catalog.list()) {
1039
+ if (seen.has(id)) continue;
1040
+ seen.add(id);
1041
+ const image = this.#catalog.supportsImage?.(id) ?? false;
1042
+ models.push({ provider, id, name: id, inputModalities: image ? ["text", "image"] : ["text"] });
1043
+ }
1044
+ return models;
1045
+ }
1046
+ resolveModel(provider, model) {
1047
+ const image = this.#catalog.supportsImage?.(model) ?? this.#catalog.requiresResponsesApi?.(model) ?? false;
1048
+ const ladder = this.#catalog.reasoningFor?.(model);
1049
+ return {
1050
+ provider,
1051
+ id: model,
1052
+ name: model,
1053
+ inputModalities: image ? ["text", "image"] : ["text"],
1054
+ context: { contextWindow: this.#catalog.contextWindowFor?.(model) ?? DEFAULT_CONTEXT_WINDOW },
1055
+ defaultMaxTokens: this.#catalog.maxTokensFor?.(model) ?? DEFAULT_MAX_TOKENS,
1056
+ ...ladder !== void 0 ? { reasoning: ladder } : {}
1057
+ };
1058
+ }
1059
+ async prepareCall(provider, model, _signal) {
1060
+ return {
1061
+ model: this.resolveModel(provider, model),
1062
+ stream: (options) => this.stream(options)
1063
+ };
1064
+ }
1065
+ /**
1066
+ * Fail fast when no gateway API URL is configured (picker stays empty too).
1067
+ * @returns the versioned API root, trailing slash stripped.
1068
+ */
1069
+ #requireBaseUrl() {
1070
+ const raw = this.#runtime?.baseUrl() ?? this.#fallbackBaseUrl;
1071
+ const baseUrl = raw.replace(/\/+$/, "");
1072
+ if (baseUrl === "") {
1073
+ throw new Error("dsh-magpie-connect: Magpie gateway API URL is not configured \u2014 open Settings \u2192 Magpie and set it");
1074
+ }
1075
+ return baseUrl;
1076
+ }
1077
+ /** Effective bearer key for this request. */
1078
+ #wireApiKey() {
1079
+ return this.#runtime?.apiKey() ?? this.#fallbackApiKey;
1080
+ }
1081
+ /**
1082
+ * Stream one chat turn from the Magpie gateway: a single pi-ai stream,
1083
+ * translated to harness chunks verbatim. Upstream failures (rate limit,
1084
+ * auth, timeout, transport) arrive as classified finish reasons, and
1085
+ * turn-level retries stay owned by DSH.
1086
+ *
1087
+ * A stall watchdog races every upstream event against a timer: on expiry
1088
+ * the upstream is aborted and the turn ends fast with a TIMEOUT error
1089
+ * instead of hanging to the SDK/harness timeout.
1090
+ */
1091
+ async *stream(options) {
1092
+ const baseUrl = this.#requireBaseUrl();
1093
+ const endpoints = this.#catalog.getEntry?.(options.model)?.nativeEndpoints ?? [];
1094
+ const hasImage = contentHasImage(options.messages);
1095
+ if (hasImage && !(this.#catalog.supportsImage?.(options.model) ?? false)) {
1096
+ throw new Error(`dsh-magpie-connect: model "${options.model}" does not accept image input`);
1097
+ }
1098
+ const context = hasImage ? await this.#imageContext(options, endpoints) : toPiContext(options, endpoints);
1099
+ const model = toPiModel(this.#providerId, baseUrl, options.model, this.#catalog);
1100
+ const controller = new AbortController();
1101
+ const harnessSignal = options.signal;
1102
+ const onHarnessAbort = () => controller.abort();
1103
+ if (harnessSignal) {
1104
+ if (harnessSignal.aborted) controller.abort();
1105
+ else harnessSignal.addEventListener("abort", onHarnessAbort, { once: true });
1106
+ }
1107
+ try {
1108
+ const events = withStallTimeout(
1109
+ this.#eventsFor(options, context, model, controller.signal),
1110
+ { firstEventTimeoutMs: this.#firstEventTimeoutMs, idleTimeoutMs: this.#idleTimeoutMs },
1111
+ { model: options.model, onTimeout: () => controller.abort() }
1112
+ );
1113
+ yield* toStreamChunks(events, model.contextWindow);
1114
+ } finally {
1115
+ harnessSignal?.removeEventListener("abort", onHarnessAbort);
1116
+ }
1117
+ }
1118
+ async #imageContext(options, endpoints) {
1119
+ const attachments = this.#resolveAttachments?.();
1120
+ if (!attachments) {
1121
+ throw new Error("dsh-magpie-connect: image input requires the attachment service");
1122
+ }
1123
+ return toPiContextWithImages(options, attachments, endpoints);
1124
+ }
1125
+ #eventsFor(options, context, model, signal) {
1126
+ const reasoningEffort = planReasoningEffort(options.reasoningEffort, options.model, this.#catalog);
1127
+ return this.#provider.stream(model, context, {
1128
+ apiKey: this.#wireApiKey(),
1129
+ signal,
1130
+ maxRetries: this.#maxRetries,
1131
+ timeoutMs: this.#timeoutMs,
1132
+ temperature: options.temperature,
1133
+ maxTokens: options.maxTokens ?? model.maxTokens,
1134
+ ...reasoningEffort !== void 0 ? { reasoningEffort } : {}
1135
+ });
1136
+ }
1137
+ /** Expose the live catalog snapshot for diagnostics. */
1138
+ catalogStatus() {
1139
+ const list = this.#catalog.list();
1140
+ return { total: list.length, exposed: list.length };
1141
+ }
1142
+ decisionFor(model) {
1143
+ const decision = this.#catalog.decision(model);
1144
+ return { allowed: decision.allowed, source: decision.source };
1145
+ }
1146
+ };
1147
+
1148
+ // src/config.ts
1149
+ import { homedir } from "node:os";
1150
+ import { join as join2 } from "node:path";
1151
+ var defaults = {
1152
+ providerId: "dsh-magpie-connect",
1153
+ displayName: "magpie",
1154
+ baseUrl: "",
1155
+ apiKey: "",
1156
+ refreshSeconds: 300,
1157
+ maxRetries: 2,
1158
+ timeoutMs: 3e5,
1159
+ firstEventTimeoutMs: 9e4,
1160
+ idleTimeoutMs: 6e4
1161
+ };
1162
+ function resolveConfig(config = {}) {
1163
+ const providerId = config.providerId ?? defaults.providerId;
1164
+ return {
1165
+ ...defaults,
1166
+ ...config,
1167
+ providerId,
1168
+ displayName: config.displayName ?? (config.providerId ?? defaults.displayName),
1169
+ baseUrl: config.baseUrl ?? defaults.baseUrl,
1170
+ apiKey: config.apiKey ?? defaults.apiKey,
1171
+ dataDir: config.dataDir ?? join2(homedir(), ".dsh-magpie-connect")
1172
+ };
1173
+ }
1174
+
1175
+ // src/settings.ts
1176
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, rename as rename2, rm as rm2 } from "node:fs/promises";
1177
+ import { dirname as dirname2, join as join3 } from "node:path";
1178
+ var SETTINGS_FILE = "settings.json";
1179
+ function defaultSettingsPath(dataDir) {
1180
+ return join3(dataDir, SETTINGS_FILE);
1181
+ }
1182
+ function normalizeBaseUrl(raw) {
1183
+ if (typeof raw !== "string" || raw.trim() === "") throw new Error("dsh-magpie-connect: baseUrl must be a non-empty http(s) URL");
1184
+ const trimmed = raw.trim();
1185
+ let url;
1186
+ try {
1187
+ url = new URL(trimmed);
1188
+ } catch {
1189
+ throw new Error(`dsh-magpie-connect: baseUrl is not a valid URL: ${trimmed}`);
1190
+ }
1191
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1192
+ throw new Error(`dsh-magpie-connect: baseUrl must use http(s): ${trimmed}`);
1193
+ }
1194
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
1195
+ }
1196
+ function normalizeHiddenModels(raw) {
1197
+ if (raw === void 0) return [];
1198
+ if (!Array.isArray(raw)) throw new Error("dsh-magpie-connect: hiddenModels must be an array of model ids");
1199
+ const out = [];
1200
+ for (const entry of raw) {
1201
+ if (typeof entry !== "string") throw new Error("dsh-magpie-connect: hiddenModels must be an array of model ids");
1202
+ const id = entry.trim();
1203
+ if (id !== "" && !out.includes(id)) out.push(id);
1204
+ }
1205
+ return out;
1206
+ }
1207
+ function normalizePageSettings(raw) {
1208
+ if (!raw || typeof raw !== "object") throw new Error("dsh-magpie-connect: settings payload must be an object");
1209
+ const record = raw;
1210
+ const out = {};
1211
+ if (record.baseUrl !== void 0) out.baseUrl = normalizeBaseUrl(record.baseUrl);
1212
+ if (record.apiKey !== void 0) {
1213
+ if (typeof record.apiKey !== "string" || record.apiKey.trim() === "") {
1214
+ throw new Error("dsh-magpie-connect: apiKey is required and must not be empty");
1215
+ }
1216
+ out.apiKey = record.apiKey.trim();
1217
+ }
1218
+ if (record.hiddenModels !== void 0) out.hiddenModels = normalizeHiddenModels(record.hiddenModels);
1219
+ return out;
1220
+ }
1221
+ function resolveEffectiveEndpoint(patch, page) {
1222
+ return {
1223
+ baseUrl: page.baseUrl ?? patch.baseUrl,
1224
+ apiKey: page.apiKey ?? patch.apiKey
1225
+ };
1226
+ }
1227
+ function isConfigured(endpoint) {
1228
+ return endpoint.baseUrl !== "" && endpoint.apiKey !== "";
1229
+ }
1230
+ var SettingsStore = class {
1231
+ #path;
1232
+ #settings = {};
1233
+ #listeners = /* @__PURE__ */ new Set();
1234
+ constructor(options = {}) {
1235
+ this.#path = options.path;
1236
+ }
1237
+ /** Load persisted settings (missing/corrupt file reads as empty, never throws). */
1238
+ async load() {
1239
+ if (!this.#path) return this.get();
1240
+ try {
1241
+ const raw = JSON.parse(await readFile2(this.#path, "utf8"));
1242
+ this.#settings = normalizePageSettings(raw);
1243
+ } catch {
1244
+ this.#settings = {};
1245
+ }
1246
+ return this.get();
1247
+ }
1248
+ get() {
1249
+ return { ...this.#settings, hiddenModels: [...this.#settings.hiddenModels ?? []] };
1250
+ }
1251
+ /** Merge a partial payload, persist, and notify listeners. */
1252
+ async save(partial) {
1253
+ const normalized = normalizePageSettings(partial);
1254
+ this.#settings = { ...this.#settings, ...normalized };
1255
+ await this.persist();
1256
+ this.#emit();
1257
+ return this.get();
1258
+ }
1259
+ /** Replace the whole document (used at startup after load). */
1260
+ replace(next) {
1261
+ this.#settings = { ...next, hiddenModels: [...next.hiddenModels ?? []] };
1262
+ this.#emit();
1263
+ }
1264
+ onChange(listener) {
1265
+ this.#listeners.add(listener);
1266
+ return () => {
1267
+ this.#listeners.delete(listener);
1268
+ };
1269
+ }
1270
+ async persist() {
1271
+ if (!this.#path) return;
1272
+ const tmp = `${this.#path}.${process.pid}.tmp`;
1273
+ await mkdir2(dirname2(this.#path), { recursive: true });
1274
+ await writeFile2(tmp, JSON.stringify(this.#settings, null, 2), "utf8");
1275
+ await rm2(this.#path, { force: true });
1276
+ await rename2(tmp, this.#path);
1277
+ }
1278
+ #emit() {
1279
+ for (const listener of [...this.#listeners]) {
1280
+ try {
1281
+ listener();
1282
+ } catch {
1283
+ }
1284
+ }
1285
+ }
1286
+ };
1287
+
1288
+ // src/index.ts
1289
+ var name = "dsh-magpie-connect";
1290
+ var inject = ["llm"];
1291
+ var SETTINGS_API = "/api/magpie-settings";
1292
+ var MODELS_API = "/api/magpie-models";
1293
+ var TEST_API = "/api/magpie-test";
1294
+ var DISCOVER_API = "/api/magpie-discover";
1295
+ function responsesOnlyFor(nativeEndpoints) {
1296
+ if (nativeEndpoints.length === 0) return false;
1297
+ const hasCompletions = nativeEndpoints.some((endpoint) => endpoint.includes("/chat/completions"));
1298
+ return !hasCompletions && nativeEndpoints.some((endpoint) => endpoint.includes("/responses"));
1299
+ }
1300
+ function toRow(id, entry) {
1301
+ return {
1302
+ id,
1303
+ displayName: entry?.displayName ?? id,
1304
+ ...entry?.contextWindow !== void 0 ? { contextWindow: entry.contextWindow } : {},
1305
+ ...entry?.maxTokens !== void 0 ? { maxTokens: entry.maxTokens } : {},
1306
+ image: entry?.image ?? false,
1307
+ responsesOnly: responsesOnlyFor(entry?.nativeEndpoints ?? []),
1308
+ reasoning: entry?.reasoning ?? false,
1309
+ efforts: entry?.efforts ?? []
1310
+ };
1311
+ }
1312
+ var currentBackend = null;
1313
+ var liveRouteSets = 0;
1314
+ var routeDisposers = null;
1315
+ function __resetSettingsRoutes() {
1316
+ currentBackend = null;
1317
+ liveRouteSets = 0;
1318
+ routeDisposers = null;
1319
+ }
1320
+ function jsonResponse(status, payload) {
1321
+ return new Response(JSON.stringify(payload), {
1322
+ status,
1323
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
1324
+ });
1325
+ }
1326
+ var routeOk = (value) => jsonResponse(200, { ok: true, value });
1327
+ var routeFail = (error) => jsonResponse(500, { ok: false, error: error instanceof Error ? error.message : String(error) });
1328
+ async function readRouteBody(request) {
1329
+ try {
1330
+ return await request.json();
1331
+ } catch {
1332
+ return {};
1333
+ }
1334
+ }
1335
+ function buildModelRows(backend) {
1336
+ const { store, catalog } = backend;
1337
+ const hidden = new Set(store.get().hiddenModels ?? []);
1338
+ const ids = /* @__PURE__ */ new Set([...catalog.list(), ...hidden]);
1339
+ return [...ids].sort().map((id) => ({ ...toRow(id, catalog.getEntry(id)), hidden: hidden.has(id) }));
1340
+ }
1341
+ function backendOrThrow() {
1342
+ if (!currentBackend) throw new Error("dsh-magpie-connect: settings backend not ready");
1343
+ return currentBackend;
1344
+ }
1345
+ async function handleSettingsGet() {
1346
+ try {
1347
+ const backend = backendOrThrow();
1348
+ const page = backend.store.get();
1349
+ const endpoint = resolveEffectiveEndpoint(backend.patchEndpoint, page);
1350
+ return routeOk({
1351
+ baseUrl: endpoint.baseUrl,
1352
+ apiKey: endpoint.apiKey,
1353
+ hiddenModels: page.hiddenModels ?? [],
1354
+ fromPage: page,
1355
+ fromPatch: backend.patchEndpoint
1356
+ });
1357
+ } catch (error) {
1358
+ return routeFail(error);
1359
+ }
1360
+ }
1361
+ async function handleSettingsPost(request) {
1362
+ try {
1363
+ const backend = backendOrThrow();
1364
+ const body = await readRouteBody(request);
1365
+ const normalized = normalizePageSettings(body);
1366
+ if (normalized.hiddenModels !== void 0) {
1367
+ normalized.hiddenModels = backend.catalog.pruneHidden(normalized.hiddenModels);
1368
+ }
1369
+ const saved = await backend.store.save(normalized);
1370
+ backend.applyPageSettings();
1371
+ const endpoint = resolveEffectiveEndpoint(backend.patchEndpoint, saved);
1372
+ return routeOk({ baseUrl: endpoint.baseUrl, apiKey: endpoint.apiKey, hiddenModels: saved.hiddenModels ?? [] });
1373
+ } catch (error) {
1374
+ return routeFail(error);
1375
+ }
1376
+ }
1377
+ async function handleModelsGet() {
1378
+ try {
1379
+ const backend = backendOrThrow();
1380
+ return routeOk({ models: buildModelRows(backend), snapshot: backend.catalog.snapshot() });
1381
+ } catch (error) {
1382
+ return routeFail(error);
1383
+ }
1384
+ }
1385
+ function probeTarget(backend, body) {
1386
+ const effective = backend.effective();
1387
+ const origin = normalizeBaseUrl(body.baseUrl ?? effective.baseUrl);
1388
+ const rawKey = typeof body.apiKey === "string" ? body.apiKey : effective.apiKey;
1389
+ const key = rawKey.trim();
1390
+ if (key === "") throw new Error("dsh-magpie-connect: apiKey is required \u2014 fill it in before asking the gateway");
1391
+ return { origin, key };
1392
+ }
1393
+ function hintGatewayFailure(error) {
1394
+ const message = error instanceof Error ? error.message : String(error);
1395
+ const hinted = /HTTP 404/.test(message) && !/网关返回 404/.test(message) ? `${message}\uFF08\u7F51\u5173\u8FD4\u56DE 404\uFF1A\u591A\u4E3A API \u5730\u5740\u8DEF\u5F84\u4E0D\u5BF9\uFF0C\u6216\u8BE5 Key \u65E0\u6548/\u65E0\u6743\u8BBF\u95EE\uFF09` : message;
1396
+ return routeFail(hinted);
1397
+ }
1398
+ async function handleTestPost(request) {
1399
+ try {
1400
+ const backend = backendOrThrow();
1401
+ const body = await readRouteBody(request);
1402
+ const { origin, key } = probeTarget(backend, body);
1403
+ const models = await fetchMagpieModels(origin, fetch, { headers: { authorization: `Bearer ${key}` } });
1404
+ return routeOk({ count: models.size, models: [...models.keys()].sort().slice(0, 50) });
1405
+ } catch (error) {
1406
+ return hintGatewayFailure(error);
1407
+ }
1408
+ }
1409
+ async function handleDiscoverPost(request) {
1410
+ try {
1411
+ const backend = backendOrThrow();
1412
+ const body = await readRouteBody(request);
1413
+ const { origin, key } = probeTarget(backend, body);
1414
+ const models = await fetchMagpieModels(origin, fetch, { headers: { authorization: `Bearer ${key}` } });
1415
+ const candidates = [...models.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([id, entry]) => toRow(id, entry));
1416
+ return routeOk({ count: candidates.length, models: candidates });
1417
+ } catch (error) {
1418
+ return hintGatewayFailure(error);
1419
+ }
1420
+ }
1421
+ function routeDefinitions() {
1422
+ return [
1423
+ {
1424
+ path: SETTINGS_API,
1425
+ methods: ["GET", "POST"],
1426
+ requestBody: "buffered",
1427
+ fetch: (request) => request.method === "POST" ? handleSettingsPost(request) : handleSettingsGet()
1428
+ },
1429
+ {
1430
+ path: MODELS_API,
1431
+ methods: ["GET"],
1432
+ requestBody: "buffered",
1433
+ fetch: () => handleModelsGet()
1434
+ },
1435
+ {
1436
+ path: TEST_API,
1437
+ methods: ["POST"],
1438
+ requestBody: "buffered",
1439
+ fetch: (request) => handleTestPost(request)
1440
+ },
1441
+ {
1442
+ path: DISCOVER_API,
1443
+ methods: ["POST"],
1444
+ requestBody: "buffered",
1445
+ fetch: (request) => handleDiscoverPost(request)
1446
+ }
1447
+ ];
1448
+ }
1449
+ function installSettingsRoutes(ctx, logger) {
1450
+ try {
1451
+ if (typeof ctx.inject !== "function") return;
1452
+ ctx.inject(["connection"], (cctx) => {
1453
+ const connection = cctx.connection;
1454
+ const register = connection?.fetch?.register;
1455
+ if (typeof register !== "function") {
1456
+ logger.warn("dsh-magpie-connect: connection fetch registry unavailable; settings page API disabled");
1457
+ return;
1458
+ }
1459
+ liveRouteSets += 1;
1460
+ try {
1461
+ if (routeDisposers === null) {
1462
+ const installed = [];
1463
+ try {
1464
+ for (const definition of routeDefinitions()) {
1465
+ try {
1466
+ installed.push(register(definition));
1467
+ } catch (error) {
1468
+ if (error instanceof Error && /already registered/.test(error.message)) {
1469
+ logger.warn(`dsh-magpie-connect: ${definition.path} already registered by another generation; sharing it`);
1470
+ continue;
1471
+ }
1472
+ throw error;
1473
+ }
1474
+ }
1475
+ } catch (error) {
1476
+ for (const dispose of installed.reverse()) {
1477
+ try {
1478
+ const result = dispose();
1479
+ if (result instanceof Promise) result.catch(() => {
1480
+ });
1481
+ } catch {
1482
+ }
1483
+ }
1484
+ throw error;
1485
+ }
1486
+ routeDisposers = installed;
1487
+ }
1488
+ } catch (error) {
1489
+ liveRouteSets -= 1;
1490
+ logger.warn(`dsh-magpie-connect: settings API unavailable: ${error instanceof Error ? error.message : String(error)}`);
1491
+ throw error;
1492
+ }
1493
+ return async () => {
1494
+ liveRouteSets -= 1;
1495
+ if (liveRouteSets <= 0) {
1496
+ liveRouteSets = 0;
1497
+ const owned = routeDisposers;
1498
+ routeDisposers = null;
1499
+ if (owned) {
1500
+ for (const dispose of owned.reverse()) await dispose();
1501
+ }
1502
+ }
1503
+ };
1504
+ });
1505
+ } catch (error) {
1506
+ logger.warn(`dsh-magpie-connect: settings API unavailable: ${error instanceof Error ? error.message : String(error)}`);
1507
+ }
1508
+ }
1509
+ function apply(ctx, config = {}) {
1510
+ const logger = ctx.logger;
1511
+ const cfg = resolveConfig(config);
1512
+ const ready = Promise.resolve({ version: "adapter" });
1513
+ if (!ctx.llm || typeof ctx.llm.registerAdapter !== "function") {
1514
+ logger.error("dsh-magpie-connect: llm service unavailable; adapter cannot register");
1515
+ return { ready };
1516
+ }
1517
+ const dataDir = cfg.dataDir;
1518
+ const statusPath = join4(dataDir, "adapter-status.json");
1519
+ const writeStatus = (status, lastError) => {
1520
+ void writeFile3(
1521
+ statusPath,
1522
+ JSON.stringify({ ...status, lastError, writtenAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
1523
+ "utf8"
1524
+ ).catch(() => {
1525
+ });
1526
+ };
1527
+ const store = new SettingsStore({ path: defaultSettingsPath(dataDir) });
1528
+ const patchEndpoint = { baseUrl: cfg.baseUrl, apiKey: cfg.apiKey };
1529
+ const effective = () => resolveEffectiveEndpoint(patchEndpoint, store.get());
1530
+ const announceTopology = () => {
1531
+ if (typeof ctx.emit !== "function") return;
1532
+ try {
1533
+ ctx.emit("llm/adapters-updated");
1534
+ } catch (error) {
1535
+ logger.warn(`dsh-magpie-connect: llm/adapters-updated emit failed: ${error instanceof Error ? error.message : String(error)}`);
1536
+ }
1537
+ };
1538
+ const catalog = new ModelCatalog({
1539
+ baseUrl: effective().baseUrl,
1540
+ refreshSeconds: cfg.refreshSeconds,
1541
+ cachePath: defaultCachePath(dataDir),
1542
+ onRefresh: (status, lastError) => {
1543
+ writeStatus(status, lastError);
1544
+ if (lastError) logger.warn(`dsh-magpie-connect: catalog refresh issue: ${lastError}`);
1545
+ },
1546
+ onInvalidate: announceTopology
1547
+ });
1548
+ const applyPageSettings = () => {
1549
+ const page = store.get();
1550
+ const endpoint = resolveEffectiveEndpoint(patchEndpoint, page);
1551
+ const catalogBaseUrl = isConfigured(endpoint) ? endpoint.baseUrl.replace(/\/+$/, "") : "";
1552
+ if (catalog.baseUrl !== catalogBaseUrl) {
1553
+ catalog.setBaseUrl(catalogBaseUrl);
1554
+ if (catalogBaseUrl !== "") {
1555
+ void catalog.refreshOnce().catch((err) => {
1556
+ logger.warn(`dsh-magpie-connect: catalog refresh after endpoint change failed: ${err instanceof Error ? err.message : String(err)}`);
1557
+ });
1558
+ }
1559
+ }
1560
+ catalog.setHidden(page.hiddenModels ?? []);
1561
+ };
1562
+ const adapter = new MagpieAdapter(catalog, {
1563
+ providerId: cfg.providerId,
1564
+ displayName: cfg.displayName,
1565
+ magpieBaseUrl: cfg.baseUrl,
1566
+ apiKey: cfg.apiKey,
1567
+ maxRetries: cfg.maxRetries,
1568
+ timeoutMs: cfg.timeoutMs,
1569
+ firstEventTimeoutMs: cfg.firstEventTimeoutMs,
1570
+ idleTimeoutMs: cfg.idleTimeoutMs,
1571
+ runtime: {
1572
+ baseUrl: () => {
1573
+ const endpoint = effective();
1574
+ return isConfigured(endpoint) ? endpoint.baseUrl.replace(/\/+$/, "") : "";
1575
+ },
1576
+ apiKey: () => effective().apiKey
1577
+ },
1578
+ resolveAttachments: () => {
1579
+ try {
1580
+ const attachments = typeof ctx.get === "function" ? ctx.get("attachments") : void 0;
1581
+ return attachments && typeof attachments.readImageRequest === "function" ? attachments : void 0;
1582
+ } catch {
1583
+ return void 0;
1584
+ }
1585
+ }
1586
+ });
1587
+ store.onChange(applyPageSettings);
1588
+ ctx.llm.registerAdapter([cfg.providerId], adapter);
1589
+ logger.info(`dsh-magpie-connect: adapter registered for "${cfg.providerId}" (catalog warms up in background)`);
1590
+ void (async () => {
1591
+ await store.load();
1592
+ applyPageSettings();
1593
+ await catalog.start().catch((err) => {
1594
+ logger.error(`dsh-magpie-connect: catalog start failed: ${err instanceof Error ? err.message : String(err)}`);
1595
+ });
1596
+ })().catch((err) => {
1597
+ logger.error(`dsh-magpie-connect: startup failed: ${err instanceof Error ? err.message : String(err)}`);
1598
+ });
1599
+ try {
1600
+ if (typeof ctx.inject === "function") {
1601
+ ctx.inject(["settings"], (settingsCtx) => {
1602
+ try {
1603
+ const scoped = settingsCtx;
1604
+ const fiber = ctx.fiber;
1605
+ scoped.effect?.(() => scoped.settings?.configure?.({ auto: false }, fiber), "dsh-magpie-connect: custom settings page");
1606
+ } catch {
1607
+ }
1608
+ });
1609
+ }
1610
+ } catch {
1611
+ }
1612
+ currentBackend = { store, catalog, patchEndpoint, effective, applyPageSettings };
1613
+ installSettingsRoutes(ctx, logger);
1614
+ const maybeEffect = ctx.effect;
1615
+ if (typeof maybeEffect === "function") {
1616
+ maybeEffect.call(ctx, () => () => {
1617
+ catalog.stop();
1618
+ });
1619
+ }
1620
+ return { ready };
1621
+ }
1622
+ export {
1623
+ DISCOVER_API,
1624
+ MODELS_API,
1625
+ MagpieAdapter,
1626
+ ModelCatalog,
1627
+ SETTINGS_API,
1628
+ SettingsStore,
1629
+ TEST_API,
1630
+ __resetSettingsRoutes,
1631
+ apply,
1632
+ inject,
1633
+ isConfigured,
1634
+ name,
1635
+ resolveConfig,
1636
+ resolveEffectiveEndpoint
1637
+ };
1638
+ //# sourceMappingURL=index.js.map