@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/LICENSE +21 -0
- package/README.md +158 -0
- package/README.zh.md +160 -0
- package/assets/icons/claude.svg +1 -0
- package/assets/icons/grok.svg +1 -0
- package/assets/icons/openai.svg +1 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.cjs +1098 -0
- package/lib/client.js +1107 -0
- package/lib/image-tools.d.ts +18 -0
- package/lib/index.d.ts +148 -0
- package/lib/index.js +1220 -0
- package/lib/invariant.d.ts +13 -0
- package/lib/invariant.js +9 -0
- package/lib/pi-ai-patch.d.ts +45 -0
- package/lib/pi-ai.d.ts +50 -0
- package/lib/routes.d.ts +43 -0
- package/package.json +160 -0
- package/src/client/icons.tsx +38 -0
- package/src/client/index.tsx +31 -0
- package/src/client/settings.tsx +847 -0
- package/src/client/toolview.tsx +76 -0
- package/src/image-tools.ts +590 -0
- package/src/index.ts +339 -0
- package/src/invariant.ts +17 -0
- package/src/pi-ai-patch.ts +94 -0
- package/src/pi-ai.ts +185 -0
- package/src/routes.ts +442 -0
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 };
|