@wxip/dsh-sub2api 0.2.2

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,1220 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { LlmError, LlmError as LlmError$1, assertUsableApiKey, attributionHeaders } from "@deepseek-ai/dsh-llm";
3
+ import { credentialRef, credentialRef as credentialRef$1 } from "@deepseek-ai/dsh-credentials";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, extname, join } from "node:path";
6
+ import { defineTool } from "@deepseek-ai/dsh-tools";
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+
11
+ //#region src/routes.ts
12
+ const ROUTES = {
13
+ get: "/plugins/dsh-sub2api/config",
14
+ set: "/plugins/dsh-sub2api/config",
15
+ discover: "/plugins/dsh-sub2api/discover",
16
+ usage: "/plugins/dsh-sub2api/usage",
17
+ status: "/plugins/dsh-sub2api/status",
18
+ attachment: "/plugins/dsh-sub2api/attachment"
19
+ };
20
+ function trustedRequest(req) {
21
+ const remote = req.socket.remoteAddress;
22
+ if (remote !== "127.0.0.1" && remote !== "::1" && remote !== "::ffff:127.0.0.1") return false;
23
+ if (req.headers["sec-fetch-site"] === "cross-site") return false;
24
+ const host = req.headers.host;
25
+ if (host === undefined) return false;
26
+ const origin = req.headers.origin;
27
+ if (origin === undefined) return true;
28
+ try {
29
+ return new URL(origin).host === new URL(`http://${host}`).host;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function readJson(req) {
35
+ const chunks = [];
36
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
37
+ const text = Buffer.concat(chunks).toString("utf8").trim();
38
+ if (text.length === 0) return {};
39
+ const value = JSON.parse(text);
40
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
41
+ }
42
+ function json(res, status, value) {
43
+ res.writeHead(status, {
44
+ "content-type": "application/json; charset=utf-8",
45
+ "cache-control": "no-store",
46
+ "x-content-type-options": "nosniff"
47
+ });
48
+ res.end(JSON.stringify(value));
49
+ }
50
+ function safeMessage(error) {
51
+ if (error instanceof Error) return error.message;
52
+ try {
53
+ const text = String(error);
54
+ return text.length > 0 ? text : "unknown error";
55
+ } catch {
56
+ return "unknown error";
57
+ }
58
+ }
59
+ function readProviderConfig(config) {
60
+ const providers = {};
61
+ for (const def of PROVIDERS) {
62
+ const profile = config.providers[def.key];
63
+ providers[def.key] = {
64
+ keyConfigured: profile.apiKeyEnv !== undefined,
65
+ models: profile.models?.map((model) => ({ ...model })) ?? []
66
+ };
67
+ }
68
+ return {
69
+ baseURL: config.baseURL,
70
+ catalogFormat: "structured-v1",
71
+ providers,
72
+ tools: { ...config.tools?.generate !== undefined ? { generate: { ...config.tools.generate } } : {} }
73
+ };
74
+ }
75
+ function legacyCatalogModel(value) {
76
+ const [rawId = "", rawName = "", rawContextWindow = ""] = value.split("|");
77
+ const id = rawId.trim();
78
+ if (id.length === 0) return undefined;
79
+ const name$1 = rawName.trim();
80
+ const parsedContextWindow = Number(rawContextWindow.trim());
81
+ const contextWindow = Number.isSafeInteger(parsedContextWindow) && parsedContextWindow > 0 ? parsedContextWindow : undefined;
82
+ return {
83
+ id,
84
+ ...name$1.length > 0 ? { name: name$1 } : {},
85
+ ...contextWindow !== undefined ? { contextWindow } : {}
86
+ };
87
+ }
88
+ function structuredCatalogModel(value) {
89
+ if (typeof value === "string") return legacyCatalogModel(value);
90
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
91
+ const raw = value;
92
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
93
+ if (id.length === 0) return undefined;
94
+ const name$1 = typeof raw.name === "string" ? raw.name.trim() : "";
95
+ const contextWindow = typeof raw.contextWindow === "number" && Number.isSafeInteger(raw.contextWindow) && raw.contextWindow > 0 ? raw.contextWindow : undefined;
96
+ const maxTokens = typeof raw.maxTokens === "number" && Number.isSafeInteger(raw.maxTokens) && raw.maxTokens > 0 ? raw.maxTokens : undefined;
97
+ const reasoningEfforts = Array.isArray(raw.reasoningEfforts) ? raw.reasoningEfforts.filter((effort) => typeof effort === "string" && effort.length > 0) : undefined;
98
+ const input = Array.isArray(raw.input) ? raw.input.filter((modality) => modality === "text" || modality === "image") : undefined;
99
+ return {
100
+ id,
101
+ ...name$1.length > 0 ? { name: name$1 } : {},
102
+ ...contextWindow !== undefined ? { contextWindow } : {},
103
+ ...maxTokens !== undefined ? { maxTokens } : {},
104
+ ...reasoningEfforts !== undefined ? { reasoningEfforts } : {},
105
+ ...input !== undefined && input.length > 0 ? { input } : {}
106
+ };
107
+ }
108
+ function readCatalogModels(value, fallback) {
109
+ if (Array.isArray(value)) return value.map(structuredCatalogModel).filter((model) => model !== undefined);
110
+ if (typeof value === "string") return value.split(/[\n,]/).map(legacyCatalogModel).filter((model) => model !== undefined);
111
+ return fallback;
112
+ }
113
+ function providerCredentialRef(platform) {
114
+ return credentialRef$1(`SUB2API_${platform.toUpperCase()}_API_KEY`);
115
+ }
116
+ function isProviderKey$1(value) {
117
+ return PROVIDERS.some((def) => def.key === value);
118
+ }
119
+ function readToolModelRef(value) {
120
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
121
+ const raw = value;
122
+ const provider = typeof raw.provider === "string" ? raw.provider.trim() : "";
123
+ const model = typeof raw.model === "string" ? raw.model.trim() : "";
124
+ if (!isProviderKey$1(provider) || model.length === 0) return undefined;
125
+ return {
126
+ provider,
127
+ model
128
+ };
129
+ }
130
+ function readImageTools(value, fallback) {
131
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fallback;
132
+ const raw = value;
133
+ const generate = readToolModelRef(raw.generate);
134
+ if (generate === undefined) return undefined;
135
+ return { ...generate !== undefined ? { generate } : {} };
136
+ }
137
+ /**
138
+
139
+ * One-shot probe key for discovery/usage: a freshly typed key wins (it is the
140
+
141
+ * one under test); otherwise fall back to the credential already stored for
142
+
143
+ * that provider, so the settings page does not force the user to re-type the
144
+
145
+ * key every time.
146
+
147
+ */
148
+ async function resolveProbeKey(ctx, routes, provider, typedKey) {
149
+ if (typedKey.length > 0) return typedKey;
150
+ if (!isProviderKey$1(provider)) throw new Error("provider 无效,应为 openai / claude / grok");
151
+ const def = PROVIDERS.find((entry) => entry.key === provider);
152
+ const profile = routes.config().providers[provider];
153
+ if (profile?.apiKeyEnv === undefined) throw new Error(`${def?.label ?? provider} 未配置 API key:请先填写 key 并保存配置,再获取模型/查看用量`);
154
+ try {
155
+ return await routes.resolveApiKey(`sub2api-${provider}`, profile);
156
+ } catch (error) {
157
+ throw new Error(`无法使用已保存的 ${def?.label ?? provider} key:${safeMessage(error)}`);
158
+ }
159
+ }
160
+ function registerRoutes(ctx, routes) {
161
+ ctx.inject(["webServer"], (webCtx) => {
162
+ const register = (path, handler) => {
163
+ webCtx.effect(() => webCtx.webServer.register({
164
+ kind: "exact",
165
+ path,
166
+ handler
167
+ }));
168
+ };
169
+ register(ROUTES.get, async (req, res) => {
170
+ if (req.method !== "GET" && req.method !== "POST") return json(res, 405, { error: "method not allowed" });
171
+ if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
172
+ if (req.method === "GET") {
173
+ json(res, 200, readProviderConfig(routes.config()));
174
+ return;
175
+ }
176
+ try {
177
+ const body = await readJson(req);
178
+ const baseURL = typeof body.baseURL === "string" ? body.baseURL.trim().replace(/\/+$/, "") : "";
179
+ if (baseURL.length === 0) return json(res, 400, { error: "baseURL is required" });
180
+ if (!/^https?:\/\//.test(baseURL)) return json(res, 400, { error: "baseURL must start with http(s)://" });
181
+ const current = routes.config();
182
+ const next = {
183
+ baseURL,
184
+ providers: {
185
+ openai: { ...current.providers.openai },
186
+ claude: { ...current.providers.claude },
187
+ grok: { ...current.providers.grok }
188
+ },
189
+ ...current.tools !== undefined ? { tools: { ...current.tools } } : {}
190
+ };
191
+ const rawProviders = typeof body.providers === "object" && body.providers !== null ? body.providers : {};
192
+ const credentials = ctx.get("credentials");
193
+ for (const def of PROVIDERS) {
194
+ const raw = rawProviders[def.key];
195
+ const profile = next.providers[def.key];
196
+ const apiKey = typeof raw?.apiKey === "string" ? raw.apiKey.trim() : "";
197
+ if (apiKey.length > 0 && credentials !== undefined) {
198
+ await credentials.set(providerCredentialRef(def.key), apiKey);
199
+ profile.apiKeyEnv = providerCredentialRef(def.key);
200
+ } else if (apiKey.length > 0 && credentials === undefined) profile.apiKeyEnv = providerCredentialRef(def.key);
201
+ const api = typeof raw?.api === "string" ? raw.api.trim() : undefined;
202
+ if (api !== undefined) if (api.length === 0) delete profile.api;
203
+ else if (API_PROTOCOLS.includes(api)) profile.api = api;
204
+ else return json(res, 400, { error: `${def.label} 的网关协议 "${api}" 无效,应为 ${API_PROTOCOLS.join(" / ")}` });
205
+ profile.models = readCatalogModels(raw?.models, profile.models ?? []);
206
+ }
207
+ const tools = readImageTools(body.tools, current.tools);
208
+ if (tools !== undefined) next.tools = tools;
209
+ else delete next.tools;
210
+ await routes.setConfig(next);
211
+ json(res, 200, {
212
+ ok: true,
213
+ ...readProviderConfig(next),
214
+ routes: routes.listRegisteredRoutes()
215
+ });
216
+ } catch (error) {
217
+ json(res, 500, { error: safeMessage(error) });
218
+ }
219
+ });
220
+ register(ROUTES.discover, async (req, res) => {
221
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
222
+ if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
223
+ try {
224
+ const body = await readJson(req);
225
+ const baseURL = typeof body.baseURL === "string" ? body.baseURL.trim().replace(/\/+$/, "") : "";
226
+ const provider = typeof body.provider === "string" ? body.provider.trim() : "";
227
+ if (baseURL.length === 0) return json(res, 400, { error: "baseURL is required" });
228
+ let apiKey;
229
+ try {
230
+ apiKey = await resolveProbeKey(ctx, routes, provider, typeof body.apiKey === "string" ? body.apiKey.trim() : "");
231
+ } catch (error) {
232
+ return json(res, 400, { error: safeMessage(error) });
233
+ }
234
+ const response = await fetch(`${gatewayApiRoot(baseURL)}/models`, {
235
+ method: "GET",
236
+ headers: { authorization: `Bearer ${apiKey}` },
237
+ signal: AbortSignal.timeout(3e4)
238
+ });
239
+ if (!response.ok) {
240
+ const text = await response.text().catch(() => "");
241
+ return json(res, response.status, { error: `HTTP ${response.status}: ${text.slice(0, 300)}` });
242
+ }
243
+ const payload = await response.json();
244
+ const models = Array.isArray(payload.data) ? payload.data.map((m) => ({
245
+ id: typeof m.id === "string" ? m.id : "",
246
+ name: typeof m.display_name === "string" ? m.display_name : typeof m.name === "string" ? m.name : undefined
247
+ })).filter((m) => m.id.length > 0) : [];
248
+ json(res, 200, {
249
+ ok: true,
250
+ models
251
+ });
252
+ } catch (error) {
253
+ json(res, 500, { error: safeMessage(error) });
254
+ }
255
+ });
256
+ register(ROUTES.usage, async (req, res) => {
257
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
258
+ if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
259
+ try {
260
+ const body = await readJson(req);
261
+ const baseURL = typeof body.baseURL === "string" ? body.baseURL.trim().replace(/\/+$/, "") : "";
262
+ const provider = typeof body.provider === "string" ? body.provider.trim() : "";
263
+ if (baseURL.length === 0) return json(res, 400, { error: "baseURL is required" });
264
+ let apiKey;
265
+ try {
266
+ apiKey = await resolveProbeKey(ctx, routes, provider, typeof body.apiKey === "string" ? body.apiKey.trim() : "");
267
+ } catch (error) {
268
+ return json(res, 400, { error: safeMessage(error) });
269
+ }
270
+ const response = await fetch(`${gatewayApiRoot(baseURL)}/usage`, {
271
+ method: "GET",
272
+ headers: { authorization: `Bearer ${apiKey}` },
273
+ signal: AbortSignal.timeout(3e4)
274
+ });
275
+ if (!response.ok) {
276
+ const text = await response.text().catch(() => "");
277
+ return json(res, response.status, { error: `HTTP ${response.status}: ${text.slice(0, 300)}` });
278
+ }
279
+ const payload = await response.json();
280
+ const parts = [];
281
+ const quota = payload.quota;
282
+ if (quota !== undefined && typeof quota.limit === "number") parts.push(`配额 ${String(quota.used ?? 0)}/${quota.limit}${typeof quota.unit === "string" ? ` ${quota.unit}` : ""}(剩余 ${String(quota.remaining ?? 0)})`);
283
+ else if (typeof payload.balance === "number") parts.push(`余额 $${payload.balance}`);
284
+ else if (typeof payload.remaining === "number") parts.push(`剩余 ${payload.remaining}${typeof payload.unit === "string" ? ` ${payload.unit}` : " USD"}`);
285
+ if (typeof payload.planName === "string") parts.push(`分组: ${payload.planName}`);
286
+ if (typeof payload.mode === "string") parts.push(`模式: ${payload.mode}`);
287
+ if (typeof payload.status === "string") parts.push(`状态: ${payload.status}`);
288
+ if (Array.isArray(payload.rate_limits)) parts.push(`限流: ${payload.rate_limits.map((r) => `${String(r.window ?? "")} ${String(r.used ?? 0)}/${String(r.limit ?? 0)}`).join(", ")}`);
289
+ const sub = payload.subscription;
290
+ if (sub !== undefined) parts.push(`订阅日/周/月: ${[
291
+ sub.daily_usage_usd,
292
+ sub.weekly_usage_usd,
293
+ sub.monthly_usage_usd
294
+ ].map((v) => typeof v === "number" ? `$${v}` : "-").join(" / ")}`);
295
+ json(res, 200, {
296
+ ok: true,
297
+ summary: parts.length > 0 ? parts.join(";") : "该 key 无配额/余额信息(unrestricted 模式)"
298
+ });
299
+ } catch (error) {
300
+ json(res, 500, { error: safeMessage(error) });
301
+ }
302
+ });
303
+ register(ROUTES.status, async (req, res) => {
304
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
305
+ if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
306
+ const config = routes.config();
307
+ const models = {};
308
+ for (const def of PROVIDERS) if (config.providers[def.key].apiKeyEnv !== undefined) models[def.route] = config.providers[def.key].models?.map((m) => m.id) ?? [];
309
+ json(res, 200, {
310
+ routes: routes.listRegisteredRoutes(),
311
+ models
312
+ });
313
+ });
314
+ webCtx.effect(() => webCtx.webServer.register({
315
+ kind: "prefix",
316
+ path: ROUTES.attachment,
317
+ handler: async (req, res) => {
318
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
319
+ if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
320
+ let ref;
321
+ try {
322
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
323
+ const raw = url.searchParams.get("ref") ?? "";
324
+ if (raw.length === 0) return json(res, 400, { error: "ref is required" });
325
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
326
+ if (typeof parsed !== "object" || parsed === null) throw new Error("ref must be an object");
327
+ const candidate = parsed;
328
+ if (typeof candidate.attachmentId !== "string" || typeof candidate.mediaType !== "string") throw new Error("ref is incomplete");
329
+ ref = candidate;
330
+ } catch {
331
+ return json(res, 400, { error: "invalid ref" });
332
+ }
333
+ const attachments = ctx.get("attachments");
334
+ if (attachments === undefined) return json(res, 503, { error: "attachment service unavailable" });
335
+ try {
336
+ const stored = await attachments.readImage(ref);
337
+ res.writeHead(200, {
338
+ "content-type": stored.ref.mediaType,
339
+ "cache-control": "private, max-age=86400",
340
+ "x-content-type-options": "nosniff"
341
+ });
342
+ res.end(Buffer.from(stored.data));
343
+ } catch {
344
+ json(res, 404, { error: "attachment not found" });
345
+ }
346
+ }
347
+ }));
348
+ });
349
+ }
350
+
351
+ //#endregion
352
+ //#region src/image-tools.ts
353
+ function getFs(ctx) {
354
+ return ctx.get("fs");
355
+ }
356
+ const GENERATE_IMAGE_NAME = "generate_image";
357
+ const DEFAULT_IMAGE_TOOL_TIMEOUT_MS = 18e4;
358
+ const DEFAULT_MAX_IMAGE_BYTES = 20971520;
359
+ const GENERATE_SIZES = [
360
+ "auto",
361
+ "256x256",
362
+ "512x512",
363
+ "1024x1024",
364
+ "1024x1536",
365
+ "1536x1024",
366
+ "1024x1792",
367
+ "1792x1024"
368
+ ];
369
+ const GENERATE_QUALITIES = [
370
+ "auto",
371
+ "low",
372
+ "medium",
373
+ "high",
374
+ "standard",
375
+ "hd"
376
+ ];
377
+ const PROVIDER_LABELS = {
378
+ openai: "OpenAI",
379
+ claude: "Claude",
380
+ grok: "Grok"
381
+ };
382
+ function isProviderKey(value) {
383
+ return value === "openai" || value === "claude" || value === "grok";
384
+ }
385
+ function mediaTypeFromBytes(data) {
386
+ if (data.length >= 8 && data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "image/png";
387
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
388
+ if (data.length >= 6 && data[0] === 71 && data[1] === 73 && data[2] === 70) return "image/gif";
389
+ if (data.length >= 12 && data[0] === 82 && data[1] === 73 && data[2] === 70 && data[3] === 70 && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "image/webp";
390
+ return "image/png";
391
+ }
392
+ function extensionForMediaType(mediaType) {
393
+ switch (mediaType) {
394
+ case "image/jpeg": return ".jpg";
395
+ case "image/webp": return ".webp";
396
+ case "image/gif": return ".gif";
397
+ default: return ".png";
398
+ }
399
+ }
400
+ function decodeDataUrl(value) {
401
+ const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/i.exec(value.trim());
402
+ if (match === null) return undefined;
403
+ const declared = match[1].toLowerCase() === "image/jpg" ? "image/jpeg" : match[1].toLowerCase();
404
+ const mediaType = declared;
405
+ return {
406
+ mediaType,
407
+ data: Buffer.from(match[2], "base64")
408
+ };
409
+ }
410
+ function sessionCwd(exec) {
411
+ return exec.agent?.session.header.cwd;
412
+ }
413
+ function resolveToolModel(config, kind) {
414
+ const ref = config.tools?.[kind];
415
+ const label = "生图";
416
+ const provider = typeof ref?.provider === "string" ? ref.provider.trim() : "";
417
+ const model = typeof ref?.model === "string" ? ref.model.trim() : "";
418
+ if (provider.length === 0 || model.length === 0) throw new Error(`sub2api: 未配置${label}模型。打开设置 → Sub2API 模型,为「全局图像工具」指定一个模型后再试`);
419
+ if (!isProviderKey(provider)) throw new Error(`sub2api: ${label}模型的平台 "${provider}" 无效,应为 openai / claude / grok`);
420
+ const baseURL = config.baseURL.trim().replace(/\/+$/, "");
421
+ if (baseURL.length === 0) throw new Error("sub2api: baseURL is not configured");
422
+ const profile = config.providers[provider];
423
+ if (profile.apiKeyEnv === undefined) throw new Error(`sub2api: ${PROVIDER_LABELS[provider]} 未配置 API key,无法调用${label}模型`);
424
+ const catalogModel$1 = profile.models?.find((entry) => entry.id === model);
425
+ const api = apiProtocolForKey(provider, profile);
426
+ const endpointRoot = api === "anthropic-messages" ? gatewayAnthropicRoot(baseURL) : gatewayApiRoot(baseURL);
427
+ return {
428
+ route: `sub2api-${provider}`,
429
+ label: PROVIDER_LABELS[provider],
430
+ profile,
431
+ model,
432
+ baseURL: endpointRoot,
433
+ api,
434
+ maxTokens: catalogModel$1?.maxTokens ?? DEFAULT_MAX_TOKENS
435
+ };
436
+ }
437
+ async function readErrorDetail(response) {
438
+ try {
439
+ const parsed = await response.json();
440
+ const message = parsed.error?.message;
441
+ if (typeof message === "string" && message.length > 0) return message;
442
+ } catch {}
443
+ return `HTTP ${response.status}`;
444
+ }
445
+ async function gatewayFetch(host, resolved, path, body, signal, accept) {
446
+ const apiKey = await host.resolveApiKey(resolved.route, resolved.profile);
447
+ let response;
448
+ try {
449
+ response = await fetch(`${resolved.baseURL}${path}`, {
450
+ method: "POST",
451
+ headers: {
452
+ authorization: `Bearer ${apiKey}`,
453
+ "content-type": "application/json",
454
+ accept,
455
+ ...attributionHeaders()
456
+ },
457
+ body: JSON.stringify(body),
458
+ signal
459
+ });
460
+ } catch (error) {
461
+ if (signal?.aborted) throw new LlmError$1("sub2api: request aborted", "ABORTED", { cause: error });
462
+ throw new LlmError$1(`sub2api: API request to ${resolved.baseURL}${path} failed`, "TRANSPORT", { cause: error });
463
+ }
464
+ if (!response.ok) throw new Error(`sub2api ${path}: ${await readErrorDetail(response)}`);
465
+ return response;
466
+ }
467
+ async function collectSseText(response) {
468
+ if (response.body === null) throw new Error("sub2api: API returned no response body");
469
+ const reader = response.body.getReader();
470
+ const decoder = new TextDecoder();
471
+ let buffer = "";
472
+ let text = "";
473
+ try {
474
+ while (true) {
475
+ const { done, value } = await reader.read();
476
+ if (done) break;
477
+ buffer += decoder.decode(value, { stream: true });
478
+ while (true) {
479
+ const idx = buffer.indexOf("\n\n");
480
+ if (idx === -1) break;
481
+ const raw = buffer.slice(0, idx);
482
+ buffer = buffer.slice(idx + 2);
483
+ const dataLines = raw.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart());
484
+ if (dataLines.length === 0) continue;
485
+ const joined = dataLines.join("\n");
486
+ if (joined === "[DONE]") return text;
487
+ for (const line of dataLines) {
488
+ if (line === "[DONE]") return text;
489
+ let chunk;
490
+ try {
491
+ chunk = JSON.parse(line);
492
+ } catch {
493
+ continue;
494
+ }
495
+ for (const choice of chunk.choices ?? []) {
496
+ const delta = choice.delta?.content ?? choice.message?.content;
497
+ if (typeof delta === "string") text += delta;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ } finally {
503
+ try {
504
+ reader.releaseLock();
505
+ } catch {}
506
+ }
507
+ return text;
508
+ }
509
+ function flattenMessageContent(content) {
510
+ if (typeof content === "string") return content;
511
+ if (!Array.isArray(content)) return "";
512
+ return content.map((part) => {
513
+ if (typeof part === "string") return part;
514
+ if (typeof part !== "object" || part === null) return "";
515
+ const record = part;
516
+ if (typeof record.text === "string") return record.text;
517
+ return "";
518
+ }).join("");
519
+ }
520
+ async function collectChatText(response) {
521
+ const contentType = response.headers.get("content-type") ?? "";
522
+ if (contentType.includes("text/event-stream")) {
523
+ const text$1 = (await collectSseText(response)).trim();
524
+ if (text$1.length === 0) throw new Error("sub2api: image model returned no text");
525
+ return text$1;
526
+ }
527
+ const payload = await response.json();
528
+ const text = flattenMessageContent(payload.choices?.[0]?.message?.content).trim();
529
+ if (text.length === 0) throw new Error("sub2api: image model returned no text");
530
+ return text;
531
+ }
532
+ async function loadRemoteImage(url, signal, maxBytes) {
533
+ const encoded = decodeDataUrl(url);
534
+ if (encoded !== undefined) return encoded;
535
+ let response;
536
+ try {
537
+ response = await fetch(url, {
538
+ method: "GET",
539
+ signal,
540
+ redirect: "follow"
541
+ });
542
+ } catch (error) {
543
+ if (signal?.aborted) throw new LlmError$1("sub2api: request aborted", "ABORTED", { cause: error });
544
+ throw new Error(`sub2api: failed to download image URL: ${error instanceof Error ? error.message : String(error)}`);
545
+ }
546
+ if (!response.ok) throw new Error(`sub2api: image URL returned HTTP ${response.status}`);
547
+ const buffer = new Uint8Array(await response.arrayBuffer());
548
+ if (buffer.byteLength > maxBytes) throw new Error(`sub2api: image URL exceeds ${maxBytes} bytes`);
549
+ const headerType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
550
+ const mediaType = headerType === "image/png" || headerType === "image/jpeg" || headerType === "image/webp" || headerType === "image/gif" ? headerType : mediaTypeFromBytes(buffer);
551
+ return {
552
+ mediaType,
553
+ data: buffer
554
+ };
555
+ }
556
+ function extractGeneratedImage(payload) {
557
+ if (typeof payload !== "object" || payload === null) return undefined;
558
+ const record = payload;
559
+ const items = Array.isArray(record.data) ? record.data : [];
560
+ for (const item of items) {
561
+ if (typeof item !== "object" || item === null) continue;
562
+ const row = item;
563
+ const revised = typeof row.revised_prompt === "string" ? row.revised_prompt : undefined;
564
+ if (typeof row.b64_json === "string" && row.b64_json.length > 0) {
565
+ const data = Buffer.from(row.b64_json, "base64");
566
+ return {
567
+ data,
568
+ mediaType: mediaTypeFromBytes(data),
569
+ ...revised !== undefined ? { revisedPrompt: revised } : {}
570
+ };
571
+ }
572
+ if (typeof row.url === "string" && row.url.length > 0) {
573
+ if (row.url.startsWith("data:")) {
574
+ const decoded = decodeDataUrl(row.url);
575
+ if (decoded !== undefined) return {
576
+ ...decoded,
577
+ ...revised !== undefined ? { revisedPrompt: revised } : {}
578
+ };
579
+ }
580
+ return {
581
+ url: row.url,
582
+ ...revised !== undefined ? { revisedPrompt: revised } : {}
583
+ };
584
+ }
585
+ }
586
+ return undefined;
587
+ }
588
+ function extractImageFromText(text) {
589
+ const dataMatch = /data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=\s]+/i.exec(text);
590
+ if (dataMatch !== null) {
591
+ const decoded = decodeDataUrl(dataMatch[0]);
592
+ if (decoded !== undefined) return {
593
+ kind: "data",
594
+ ...decoded
595
+ };
596
+ }
597
+ const urlMatch = /https?:\/\/\S+\.(?:png|jpe?g|webp|gif)(?:\?\S*)?/i.exec(text);
598
+ if (urlMatch !== null) return {
599
+ kind: "url",
600
+ url: urlMatch[0].replace(/[),.;]+$/, "")
601
+ };
602
+ const markdown = /!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/i.exec(text);
603
+ if (markdown !== null) return {
604
+ kind: "url",
605
+ url: markdown[1]
606
+ };
607
+ return undefined;
608
+ }
609
+ function defaultOutputName(mediaType) {
610
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").replace("T", "-").slice(0, 19);
611
+ return `generated-${stamp}${extensionForMediaType(mediaType)}`;
612
+ }
613
+ async function writeGeneratedFile(ctx, exec, requestedPath, data, mediaType) {
614
+ const fs = getFs(ctx);
615
+ if (fs === undefined) throw new Error("cannot write generated image: filesystem service is not mounted");
616
+ const cwd = sessionCwd(exec);
617
+ const rawPath = requestedPath !== undefined && requestedPath.trim().length > 0 ? requestedPath.trim() : defaultOutputName(mediaType);
618
+ const withExt = extname(rawPath).length === 0 ? `${rawPath}${extensionForMediaType(mediaType)}` : rawPath;
619
+ const target = await fs.resolve(withExt, {
620
+ ...cwd !== undefined ? { cwd } : {},
621
+ signal: exec.signal
622
+ });
623
+ if (cwd !== undefined) {
624
+ const root = await fs.resolve(".", {
625
+ cwd,
626
+ signal: exec.signal
627
+ });
628
+ if (!fs.contains(root, target)) throw new Error(`generate_image can only write inside the session workspace; refused "${target.displayPath}"`);
629
+ }
630
+ const abs = fs.processPath(target);
631
+ await mkdir(dirname(abs), { recursive: true });
632
+ await writeFile(abs, data);
633
+ return target.displayPath;
634
+ }
635
+ async function generateViaImagesApi(host, resolved, args, signal, maxBytes) {
636
+ const response = await gatewayFetch(host, resolved, "/images/generations", {
637
+ model: resolved.model,
638
+ prompt: args.prompt,
639
+ n: 1,
640
+ response_format: "b64_json",
641
+ ...args.size !== undefined && args.size !== "auto" ? { size: args.size } : {},
642
+ ...args.quality !== undefined && args.quality !== "auto" ? { quality: args.quality } : {}
643
+ }, signal, "application/json");
644
+ const payload = await response.json();
645
+ const image = extractGeneratedImage(payload);
646
+ if (image === undefined) throw new Error("sub2api: image API returned no image data");
647
+ if (image.data !== undefined && image.mediaType !== undefined) return {
648
+ data: image.data,
649
+ mediaType: image.mediaType,
650
+ ...image.revisedPrompt !== undefined ? { revisedPrompt: image.revisedPrompt } : {}
651
+ };
652
+ if (image.url !== undefined) {
653
+ const downloaded = await loadRemoteImage(image.url, signal, maxBytes);
654
+ return {
655
+ ...downloaded,
656
+ ...image.revisedPrompt !== undefined ? { revisedPrompt: image.revisedPrompt } : {}
657
+ };
658
+ }
659
+ throw new Error("sub2api: image API returned no image data");
660
+ }
661
+ async function generateViaChat(host, resolved, prompt, signal, maxBytes) {
662
+ const response = await gatewayFetch(host, resolved, "/chat/completions", {
663
+ model: resolved.model,
664
+ messages: [{
665
+ role: "user",
666
+ content: `Generate an image for this prompt and return the image itself (as a data URL or a direct image URL), not a description:\n\n${prompt}`
667
+ }],
668
+ stream: true,
669
+ stream_options: { include_usage: true }
670
+ }, signal, "text/event-stream");
671
+ const text = await collectChatText(response);
672
+ const extracted = extractImageFromText(text);
673
+ if (extracted === undefined) throw new Error(`sub2api: chat image model did not return image data. Response preview: ${text.slice(0, 240)}`);
674
+ if (extracted.kind === "data") return {
675
+ data: extracted.data,
676
+ mediaType: extracted.mediaType
677
+ };
678
+ return await loadRemoteImage(extracted.url, signal, maxBytes);
679
+ }
680
+ function registerImageTools(ctx, host) {
681
+ ctx.inject(["tools", "systemPrompt"], (toolCtx) => {
682
+ toolCtx.systemPrompt.section({
683
+ name: "tool:generate_image",
684
+ order: 119,
685
+ text: "Use the generate_image tool to create an image with the configured image model and write it to the workspace. Call it when the current chat model cannot generate images. The tool returns the saved file path, not the image bytes."
686
+ });
687
+ toolCtx.tools.register(defineTool({
688
+ name: GENERATE_IMAGE_NAME,
689
+ description: "Generate an image with the configured image model and write it to the workspace. Use this when the current chat model cannot generate images. Returns the saved file path.",
690
+ parameters: {
691
+ prompt: {
692
+ type: "string",
693
+ required: true,
694
+ description: "Image generation prompt."
695
+ },
696
+ file_path: {
697
+ type: "string",
698
+ description: "Workspace path to write. Defaults to generated-<timestamp>.png in the session cwd."
699
+ },
700
+ size: {
701
+ type: "string",
702
+ enum: [...GENERATE_SIZES],
703
+ description: "Requested size when the image API supports it."
704
+ },
705
+ quality: {
706
+ type: "string",
707
+ enum: [...GENERATE_QUALITIES],
708
+ description: "Requested quality when the image API supports it."
709
+ }
710
+ },
711
+ output: {
712
+ schema: {
713
+ type: "object",
714
+ additionalProperties: false,
715
+ properties: {
716
+ path: {
717
+ type: "string",
718
+ required: true
719
+ },
720
+ model: {
721
+ type: "string",
722
+ required: true
723
+ },
724
+ mediaType: {
725
+ type: "string",
726
+ required: true
727
+ },
728
+ bytes: {
729
+ type: "integer",
730
+ required: true
731
+ },
732
+ revisedPrompt: { type: "string" },
733
+ attachment: {
734
+ type: "object",
735
+ additionalProperties: false,
736
+ properties: {
737
+ attachmentId: {
738
+ type: "string",
739
+ required: true
740
+ },
741
+ mediaType: {
742
+ type: "string",
743
+ required: true
744
+ },
745
+ bytes: {
746
+ type: "integer",
747
+ required: true
748
+ },
749
+ width: {
750
+ type: "integer",
751
+ required: true
752
+ },
753
+ height: {
754
+ type: "integer",
755
+ required: true
756
+ },
757
+ name: { type: "string" },
758
+ originalDimensions: {
759
+ type: "object",
760
+ additionalProperties: false,
761
+ properties: {
762
+ width: {
763
+ type: "integer",
764
+ required: true
765
+ },
766
+ height: {
767
+ type: "integer",
768
+ required: true
769
+ }
770
+ }
771
+ }
772
+ }
773
+ }
774
+ }
775
+ },
776
+ render: (_args, value) => {
777
+ const blocks = [];
778
+ const attachment = value.attachment;
779
+ if (attachment !== undefined && typeof attachment === "object" && attachment !== null) blocks.push({
780
+ type: "image",
781
+ attachment
782
+ });
783
+ blocks.push({
784
+ type: "text",
785
+ text: [
786
+ `<path>${value.path}</path>`,
787
+ "<type>image</type>",
788
+ "<content>",
789
+ `${value.mediaType}, ${value.bytes} bytes, model ${value.model}`,
790
+ value.revisedPrompt !== undefined ? `revised prompt: ${value.revisedPrompt}` : "",
791
+ "</content>"
792
+ ].filter((line) => line.length > 0).join("\n")
793
+ });
794
+ return blocks;
795
+ }
796
+ },
797
+ timeoutMs: DEFAULT_IMAGE_TOOL_TIMEOUT_MS,
798
+ async execute(args, exec) {
799
+ const prompt = args.prompt.trim();
800
+ if (prompt.length === 0) throw new Error("prompt must be a non-empty string");
801
+ const resolved = resolveToolModel(host.config(), "generate");
802
+ const maxBytes = ctx.get("attachments")?.imageLimits.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES;
803
+ let image;
804
+ try {
805
+ image = await generateViaImagesApi(host, resolved, {
806
+ prompt,
807
+ ...args.size !== undefined ? { size: args.size } : {},
808
+ ...args.quality !== undefined ? { quality: args.quality } : {}
809
+ }, exec.signal, maxBytes);
810
+ } catch (error) {
811
+ const message = error instanceof Error ? error.message : String(error);
812
+ if (!/HTTP 404|HTTP 405|HTTP 501|not found|unknown endpoint|does not exist|not implemented/i.test(message)) throw error;
813
+ image = await generateViaChat(host, resolved, prompt, exec.signal, maxBytes);
814
+ }
815
+ const path = await writeGeneratedFile(ctx, exec, args.file_path, image.data, image.mediaType);
816
+ let attachment;
817
+ const attachmentStore = ctx.get("attachments");
818
+ if (attachmentStore !== undefined) try {
819
+ attachment = await attachmentStore.saveImage({
820
+ data: image.data,
821
+ mediaType: image.mediaType,
822
+ ...path.length > 0 ? { name: basename(path) } : {}
823
+ });
824
+ } catch {
825
+ attachment = undefined;
826
+ }
827
+ return {
828
+ path,
829
+ model: `${resolved.route}/${resolved.model}`,
830
+ mediaType: image.mediaType,
831
+ bytes: image.data.byteLength,
832
+ ...image.revisedPrompt !== undefined ? { revisedPrompt: image.revisedPrompt } : {},
833
+ ...attachment !== undefined ? { attachment } : {}
834
+ };
835
+ },
836
+ presentCall(args) {
837
+ return {
838
+ card: "generic",
839
+ title: `Generate image${args.file_path !== undefined ? ` ${args.file_path}` : ""}`,
840
+ ...args.file_path !== undefined ? { locations: [{ path: args.file_path }] } : {}
841
+ };
842
+ },
843
+ presentResult(_args, result) {
844
+ const imageBlock = result.content.find((b) => b.type === "image");
845
+ if (imageBlock !== undefined) return {
846
+ card: "generic",
847
+ content: [imageBlock]
848
+ };
849
+ const textBlock = result.content.find((b) => b.type === "text");
850
+ if (textBlock !== undefined && textBlock.type === "text") {
851
+ const pathLine = textBlock.text.match(/<path>(.*?)<\/path>/)?.[1];
852
+ if (pathLine !== undefined) return {
853
+ card: "generic",
854
+ content: [{
855
+ type: "text",
856
+ text: pathLine
857
+ }]
858
+ };
859
+ }
860
+ return undefined;
861
+ }
862
+ }));
863
+ });
864
+ }
865
+
866
+ //#endregion
867
+ //#region src/pi-ai.ts
868
+ const PI_AI_NS = "llm-pi-ai";
869
+ const ROUTE_PREFIX = "sub2api-";
870
+ /** pi-ai thinking levels a profile may declare (catalog `THINKING_LEVELS`). */
871
+ const THINKING_LEVELS = [
872
+ "off",
873
+ "minimal",
874
+ "low",
875
+ "medium",
876
+ "high",
877
+ "xhigh",
878
+ "max"
879
+ ];
880
+ /**
881
+ * Request modalities a catalog model declares to the harness. An explicit
882
+ * `input` wins; absent/empty falls back to a family guess — frontier
883
+ * multimodal families accept images, everything else stays text-only (the
884
+ * official harness posture: a hand-entered model is text-only until it says
885
+ * otherwise).
886
+ */
887
+ function catalogInputModalities(model) {
888
+ if (model.input !== undefined && model.input.length > 0) return [...model.input];
889
+ return /^(gpt|o[1-9]|claude|gemini|grok|glm|qwen|kimi|moonshot|minimax|mistral|llama|phi|command|jamba|codex|sora|veo|imagen|dall-e)/i.test(model.id) ? ["text", "image"] : ["text"];
890
+ }
891
+ /**
892
+ * Map this plugin's reasoning-effort ids (OpenAI vocabulary — `none`,
893
+ * `xhigh`, `max`, …) onto pi-ai's level keys. `none` is not a pi-ai level; it
894
+ * becomes `off` with wire spelling `none`, which pi-ai dispatches as
895
+ * `reasoning_effort: "none"` (chat/completions) or `reasoning:{effort:"none"}`
896
+ * (responses) — exactly what this plugin used to send. An empty list declares
897
+ * a non-reasoning model, as does a list containing only off/none; unmappable
898
+ * ids are dropped.
899
+ */
900
+ function translateReasoningEfforts(model) {
901
+ const ids = model.reasoningEfforts;
902
+ if (ids === undefined) {
903
+ if (/image/i.test(model.id)) return false;
904
+ return {
905
+ low: "low",
906
+ medium: "medium",
907
+ high: "high"
908
+ };
909
+ }
910
+ if (ids.length === 0) return false;
911
+ const efforts = {};
912
+ for (const id of ids) if (id === "none") efforts.off = "none";
913
+ else if (THINKING_LEVELS.includes(id)) efforts[id] = id;
914
+ if (Object.keys(efforts).length === 0) return undefined;
915
+ return Object.keys(efforts).some((level) => level !== "off") ? efforts : false;
916
+ }
917
+ /** One configured catalog model, translated onto pi-ai's per-model fields. */
918
+ function translateModel(model) {
919
+ const reasoningEfforts = translateReasoningEfforts(model);
920
+ return {
921
+ id: model.id,
922
+ ...model.name !== undefined && model.name.length > 0 ? { name: model.name } : {},
923
+ ...model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {},
924
+ ...model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {},
925
+ input: catalogInputModalities(model),
926
+ ...reasoningEfforts !== undefined ? { reasoningEfforts } : {}
927
+ };
928
+ }
929
+ /**
930
+ * Translate one sub2api group into a hand-declared llm-pi-ai provider profile.
931
+ * `apiKeyEnv` passes through verbatim (the harness resolves it per request
932
+ * through `ctx.credentials`); routes without a key are skipped by the caller.
933
+ *
934
+ * The settings store the bare gateway host; the protocols join it differently.
935
+ * OpenAI-compatible SDKs append their endpoint to the `/v1` API root, while
936
+ * `@anthropic-ai/sdk` treats the given URL as the bare host and appends
937
+ * `/v1/messages` itself — so OpenAI-style routes get the `/v1`-rooted URL and
938
+ * the anthropic route gets the bare host.
939
+ */
940
+ function translateProfile(key, profile, baseURL, label) {
941
+ const api = apiProtocolForKey(key, profile);
942
+ return {
943
+ ...profile.apiKeyEnv !== undefined ? { apiKeyEnv: profile.apiKeyEnv } : {},
944
+ displayName: `Sub2API ${label}`,
945
+ api,
946
+ baseURL: api === "anthropic-messages" ? gatewayAnthropicRoot(baseURL) : gatewayApiRoot(baseURL),
947
+ models: (profile.models ?? []).map(translateModel),
948
+ defaultContextWindow: DEFAULT_CONTEXT_WINDOW,
949
+ defaultMaxTokens: DEFAULT_MAX_TOKENS,
950
+ defaultInput: ["text"],
951
+ retryPolicy: {
952
+ mode: "normal",
953
+ maxRetries: 5,
954
+ retryableCodes: [
955
+ "RATE_LIMIT",
956
+ "SERVER",
957
+ "TIMEOUT",
958
+ "TRANSPORT",
959
+ "EMPTY_RESPONSE"
960
+ ],
961
+ backoff: {
962
+ initialDelayMs: 1e3,
963
+ maxDelayMs: 12e4,
964
+ jitterRatio: .2
965
+ }
966
+ }
967
+ };
968
+ }
969
+ function translateToPiAi(config) {
970
+ const baseURL = (config.baseURL ?? "").trim().replace(/\/+$/, "");
971
+ if (baseURL.length === 0) return {};
972
+ const profiles = {};
973
+ for (const def of PROVIDERS) {
974
+ const profile = config.providers[def.key];
975
+ if (profile.apiKeyEnv === undefined) continue;
976
+ const models = (profile.models ?? []).filter((model) => model.id.length > 0);
977
+ if (models.length === 0) continue;
978
+ profiles[def.route] = translateProfile(def.key, {
979
+ ...profile,
980
+ models
981
+ }, baseURL, def.label);
982
+ }
983
+ return profiles;
984
+ }
985
+ async function syncPiAiProfiles(ctx, config) {
986
+ const settings = ctx.get("settings");
987
+ if (settings === undefined) return;
988
+ const current = settings.describe().find((section) => section.ns === PI_AI_NS)?.value;
989
+ const providers = { ...current?.providers ?? {} };
990
+ for (const route of Object.keys(providers)) if (route.startsWith(ROUTE_PREFIX)) delete providers[route];
991
+ Object.assign(providers, translateToPiAi(config));
992
+ const next = { providers };
993
+ const before = JSON.stringify(current?.providers ?? {});
994
+ if (JSON.stringify(providers) === before) return;
995
+ await settings.replace(PI_AI_NS, next);
996
+ }
997
+
998
+ //#endregion
999
+ //#region src/pi-ai-patch.ts
1000
+ /** Guard marker; its presence means the patch is already applied. */
1001
+ const MARKER = "assistant.usage !== undefined";
1002
+ /** The exact upstream expression this patch guards. */
1003
+ const TARGET = "calculateContextTokens(assistant.usage) > 0";
1004
+ /** Candidate locations of the pi-ai estimate module inside a dsh install. */
1005
+ function candidateEstimatePaths() {
1006
+ const roots = new Set();
1007
+ const global = spawnSync("npm", ["root", "-g"], {
1008
+ encoding: "utf8",
1009
+ windowsHide: true
1010
+ });
1011
+ if (global.status === 0 && global.stdout.trim().length > 0) roots.add(global.stdout.trim());
1012
+ if (process.env.npm_config_prefix !== undefined) roots.add(join(process.env.npm_config_prefix, "lib", "node_modules"));
1013
+ roots.add(join(homedir(), ".npm-global", "lib", "node_modules"));
1014
+ const dshHome = process.env.DSH_HOME !== undefined ? process.env.DSH_HOME : join(homedir(), ".dsh");
1015
+ const paths = [];
1016
+ for (const root of roots) {
1017
+ paths.push(join(root, "@deepseek-ai", "dsh", "node_modules", "@earendil-works", "pi-ai", "dist", "utils", "estimate.js"));
1018
+ paths.push(join(root, "@deepseek-ai", "dsh", "node_modules", "@deepseek-ai", "dsh-llm-pi-ai", "node_modules", "@earendil-works", "pi-ai", "dist", "utils", "estimate.js"));
1019
+ }
1020
+ paths.push(join(dshHome, "profiles", "web", "node_modules", "@deepseek-ai", "dsh-llm-pi-ai", "node_modules", "@earendil-works", "pi-ai", "dist", "utils", "estimate.js"));
1021
+ return paths;
1022
+ }
1023
+ function applyPiAiMultiTurnPatch() {
1024
+ const file = candidateEstimatePaths().find((candidate) => existsSync(candidate));
1025
+ if (file === undefined) return {
1026
+ kind: "skipped",
1027
+ reason: "pi-ai estimate.js not found under the dsh install"
1028
+ };
1029
+ let source;
1030
+ try {
1031
+ source = readFileSync(file, "utf8");
1032
+ } catch (error) {
1033
+ return {
1034
+ kind: "skipped",
1035
+ reason: `cannot read ${file}: ${String(error)}`
1036
+ };
1037
+ }
1038
+ if (source.includes(MARKER)) return {
1039
+ kind: "already",
1040
+ file
1041
+ };
1042
+ const index = source.indexOf(TARGET);
1043
+ if (index === -1) return {
1044
+ kind: "skipped",
1045
+ reason: `target pattern not found in ${file} (pi-ai layout changed?)`
1046
+ };
1047
+ const lineStart = source.lastIndexOf("\n", index) + 1;
1048
+ const indent = source.slice(lineStart, index).match(/^[ \t]*/)?.[0] ?? "";
1049
+ const replacement = `${MARKER} &&\n${indent}${TARGET}`;
1050
+ const next = source.slice(0, index) + replacement + source.slice(index + TARGET.length);
1051
+ try {
1052
+ writeFileSync(file, next);
1053
+ } catch (error) {
1054
+ return {
1055
+ kind: "skipped",
1056
+ reason: `cannot write ${file}: ${String(error)}`
1057
+ };
1058
+ }
1059
+ return {
1060
+ kind: "patched",
1061
+ file
1062
+ };
1063
+ }
1064
+
1065
+ //#endregion
1066
+ //#region src/index.ts
1067
+ const name = "llm-sub2api";
1068
+ const inject = [
1069
+ "llm",
1070
+ "settings",
1071
+ "credentials"
1072
+ ];
1073
+ const NS = "llm-sub2api";
1074
+ const DEFAULT_CONTEXT_WINDOW = 128e3;
1075
+ const DEFAULT_MAX_TOKENS = 8192;
1076
+ const REASONING_EFFORTS = [
1077
+ {
1078
+ id: "low",
1079
+ name: "Low"
1080
+ },
1081
+ {
1082
+ id: "medium",
1083
+ name: "Medium"
1084
+ },
1085
+ {
1086
+ id: "high",
1087
+ name: "High"
1088
+ }
1089
+ ];
1090
+ const PROVIDERS = [
1091
+ {
1092
+ key: "openai",
1093
+ route: "sub2api-openai",
1094
+ label: "OpenAI",
1095
+ icon: "openai"
1096
+ },
1097
+ {
1098
+ key: "claude",
1099
+ route: "sub2api-claude",
1100
+ label: "Claude",
1101
+ icon: "claude"
1102
+ },
1103
+ {
1104
+ key: "grok",
1105
+ route: "sub2api-grok",
1106
+ label: "Grok",
1107
+ icon: "grok"
1108
+ }
1109
+ ];
1110
+ function readConfig(config) {
1111
+ return {
1112
+ baseURL: config.baseURL.get(),
1113
+ providers: structuredClone(config.providers.get()),
1114
+ tools: structuredClone(config.tools.get())
1115
+ };
1116
+ }
1117
+ const catalogModel = z.object({
1118
+ id: z.string().required(),
1119
+ name: z.string(),
1120
+ contextWindow: z.number().step(1).min(1),
1121
+ maxTokens: z.number().step(1).min(1),
1122
+ input: z.array(z.union([z.const("text"), z.const("image")])).default([]),
1123
+ reasoningEfforts: z.array(z.string()).default(REASONING_EFFORTS.map((effort) => effort.id))
1124
+ });
1125
+ const apiProtocol = z.union([
1126
+ z.const("openai-completions"),
1127
+ z.const("openai-responses"),
1128
+ z.const("anthropic-messages")
1129
+ ]);
1130
+ const providerProfile = z.object({
1131
+ apiKeyEnv: z.string().role("credential-ref"),
1132
+ api: apiProtocol,
1133
+ models: z.array(catalogModel)
1134
+ });
1135
+ const imageToolModelRef = z.object({
1136
+ provider: z.string(),
1137
+ model: z.string()
1138
+ });
1139
+ const Config = z.object({
1140
+ baseURL: z.string().default("").volatile(),
1141
+ providers: z.object({
1142
+ openai: providerProfile,
1143
+ claude: providerProfile,
1144
+ grok: providerProfile
1145
+ }).default({}).volatile(),
1146
+ tools: z.object({ generate: imageToolModelRef }).default({}).volatile()
1147
+ });
1148
+ const API_PROTOCOLS = [
1149
+ "openai-completions",
1150
+ "openai-responses",
1151
+ "anthropic-messages"
1152
+ ];
1153
+ /**
1154
+ * The wire protocol each sub2api platform group speaks natively at the
1155
+ * gateway. Openai groups are served upstream through the Responses API and
1156
+ * Claude groups through the Messages API; grok groups are
1157
+ * chat-completions. Speaking the native protocol avoids the gateway's
1158
+ * chat/completions ↔ native conversion, which drops/misaligns tool-call
1159
+ * names and ids for parallel calls. A provider profile may override.
1160
+ */
1161
+ const DEFAULT_PROTOCOL = {
1162
+ openai: "openai-responses",
1163
+ claude: "anthropic-messages",
1164
+ grok: "openai-completions"
1165
+ };
1166
+ function apiProtocolForKey(key, profile) {
1167
+ return profile.api ?? DEFAULT_PROTOCOL[key];
1168
+ }
1169
+ function gatewayApiRoot(baseURL) {
1170
+ const cleaned = (baseURL ?? "").trim().replace(/\/+$/, "");
1171
+ if (cleaned.length === 0) return "";
1172
+ return /\/v1$/i.test(cleaned) ? cleaned : `${cleaned}/v1`;
1173
+ }
1174
+ function gatewayAnthropicRoot(baseURL) {
1175
+ return gatewayApiRoot(baseURL).replace(/\/v1$/i, "");
1176
+ }
1177
+ function resolveAdapterOptions(config) {
1178
+ const baseURL = (config.baseURL ?? "").trim().replace(/\/+$/, "");
1179
+ if (baseURL.length > 0 && !/^https?:\/\//.test(baseURL)) throw new Error("llm-sub2api: baseURL must start with http(s)://");
1180
+ return { baseURL };
1181
+ }
1182
+ function apply(ctx, config) {
1183
+ const current = () => readConfig(config);
1184
+ const namespace = ctx.fiber.entry?.options.id ?? NS;
1185
+ resolveAdapterOptions(current());
1186
+ ctx.effect(() => ctx.settings.configure({ auto: false }));
1187
+ const resolveApiKey = async (route, profile) => {
1188
+ if (profile.apiKeyEnv === undefined) throw new LlmError(`sub2api: no API key configured for route "${route}"`, "MISSING_CREDENTIAL");
1189
+ const ref = credentialRef(profile.apiKeyEnv);
1190
+ const credentials = ctx.get("credentials");
1191
+ const hit = credentials !== undefined ? await credentials.resolve(ref) : undefined;
1192
+ if (hit !== undefined && hit.value.length > 0) return assertUsableApiKey(hit.value, "llm-sub2api", ref);
1193
+ throw new LlmError(`sub2api: no credential for provider route "${route}"; its profile resolves ${profile.apiKeyEnv}, which is not set — store it through the credentials service (the web Models page writes it) or export it`, "MISSING_CREDENTIAL");
1194
+ };
1195
+ const syncPiAi = () => {
1196
+ syncPiAiProfiles(ctx, current()).catch((error) => {
1197
+ ctx.logger.error("llm-sub2api: refused to update llm-pi-ai profiles; keeping the previously registered routes");
1198
+ ctx.logger.error(error);
1199
+ });
1200
+ };
1201
+ registerRoutes(ctx, {
1202
+ config: () => current(),
1203
+ setConfig: async (next) => {
1204
+ resolveAdapterOptions(next);
1205
+ await ctx.settings.replace(namespace, next);
1206
+ await syncPiAiProfiles(ctx, current());
1207
+ },
1208
+ listRegisteredRoutes: () => ctx.llm.listProviders().map((info) => info.id).filter((route) => route.startsWith("sub2api-")),
1209
+ resolveApiKey
1210
+ });
1211
+ registerImageTools(ctx, {
1212
+ config: () => current(),
1213
+ resolveApiKey
1214
+ });
1215
+ syncPiAi();
1216
+ ctx.on("loader/volatile-update", syncPiAi);
1217
+ }
1218
+
1219
+ //#endregion
1220
+ export { API_PROTOCOLS, Config, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, PI_AI_NS, PROVIDERS, REASONING_EFFORTS, ROUTE_PREFIX, apiProtocolForKey, apply, applyPiAiMultiTurnPatch, gatewayAnthropicRoot, gatewayApiRoot, inject, name, readConfig, syncPiAiProfiles, translateToPiAi };