@dsh-plus/llm-pi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1247 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { deepEqualJson, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
4
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
5
+ import { request } from "node:http";
6
+ import { request as request$1 } from "node:https";
7
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import HttpsProxyAgentModule from "https-proxy-agent";
10
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
11
+ import { pathToFileURL } from "node:url";
12
+ import * as vendoredPiAi from "@earendil-works/pi-ai";
13
+ import * as vendoredCatalog from "@earendil-works/pi-ai/providers/all";
14
+ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
15
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
16
+ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
17
+ import * as vendoredLlm from "@deepseek-ai/dsh-llm";
18
+ import * as vendoredPiAiAdapter from "@deepseek-ai/dsh-llm-pi-ai";
19
+
20
+ //#region src/config.ts
21
+ /** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
22
+ const SETTINGS_NS = settingsNamespace("dsh-plus-llm-pi");
23
+ /** 本插件可为手写 route 提供的协议实现(与官方 PROTOCOLS 表一致)。 */
24
+ const PROTOCOL_IDS = [
25
+ "openai-completions",
26
+ "openai-responses",
27
+ "anthropic-messages"
28
+ ];
29
+ /** pi-ai 思考档位,升级序。 */
30
+ const THINKING_LEVELS = [
31
+ "off",
32
+ "minimal",
33
+ "low",
34
+ "medium",
35
+ "high",
36
+ "xhigh",
37
+ "max"
38
+ ];
39
+ const MODALITIES = ["text", "image"];
40
+ const DEFAULT_CONTEXT_WINDOW = 262144;
41
+ const DEFAULT_MAX_TOKENS = 32768;
42
+ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
43
+ /** dsh-timeout 的定时器上限(与官方 MAX_TIMER_DELAY_MS 对齐)。 */
44
+ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
45
+ /** 键为可选档位,值为线协议拼写;仅 off 可留空(支持但不发送参数)。 */
46
+ const reasoningEfforts = z.dict(z.union([z.string(), z.const(null)]), z.union(THINKING_LEVELS));
47
+ const thinkingBudgets = z.object({
48
+ minimal: z.number(),
49
+ low: z.number(),
50
+ medium: z.number(),
51
+ high: z.number()
52
+ });
53
+ /**
54
+ * compat 开放字典:承载 pi-ai 的全量 compat 字段(按协议分型,
55
+ * 字段集与值校验见 compat.ts,在 settings 写入与 profile 构建时执行)。
56
+ * schema 层不收紧,是因为字段集取决于本条目的 api,schema 无法表达条件分型。
57
+ */
58
+ const compatDict = z.dict(z.any());
59
+ const modelEntry = z.object({
60
+ id: z.string().required().description("模型 id(发送给 provider 的标识)"),
61
+ extends: z.string().description("继承源:\"provider/model\" 或裸 model id(随 provider 级 extends 源);缺省先查内置目录同名模型"),
62
+ name: z.string().description("选择器显示名;缺省继承内置目录名,再退化为 id"),
63
+ contextWindow: z.number().step(1).min(1).description("上下文容量(覆盖继承值)"),
64
+ maxTokens: z.number().step(1).min(1).description("输出能力上限;显式配置同时成为无 cap 请求的默认 cap"),
65
+ input: z.array(z.union(MODALITIES)).description("请求模态;缺省继承内置目录,再退化 route defaultInput"),
66
+ reasoningEfforts: z.union([z.const(false), reasoningEfforts]).description("可选 reasoning 档位:false=非推理模型;dict=档位→线值映射;缺省继承内置目录能力"),
67
+ compat: compatDict.description("模型级 compat(字段级合并,压过 route 级与继承值)")
68
+ });
69
+ const providerProfile = z.object({
70
+ extends: z.string().description("provider 级继承:内置 provider id,提供 api/baseURL 默认值与模型 extends 的缺省查找源"),
71
+ displayName: z.string().description("选择器显示名;缺省为 route 键"),
72
+ api: z.union(PROTOCOL_IDS).description("线协议;缺省逐模型取继承值的 api,全部一致时作为 route 协议"),
73
+ baseURL: z.string().description("端点;缺省继承 extends 源 provider 的端点"),
74
+ apiKeyEnv: z.string().role("credential-ref").description("凭据引用名(凭据服务/环境变量)"),
75
+ headers: z.dict(z.string()).description("provider 请求头(Harness 署名头保留名优先)"),
76
+ compat: compatDict.description("route 级 compat 默认(逐模型按字段生效)"),
77
+ defaultContextWindow: z.number().step(1).min(1).description("模型与继承源都未标注时的上下文容量兜底"),
78
+ defaultMaxTokens: z.number().step(1).min(1).description("模型与继承源都未标注时的输出能力兜底"),
79
+ defaultInput: z.array(z.union(MODALITIES)).description("模型与继承源都未声明时的模态兜底(不可为空)").default(["text"]),
80
+ reasoning: z.union(THINKING_LEVELS).description("provider 默认 reasoning 档位"),
81
+ thinkingBudgets: thinkingBudgets.description("支持 token 预算的推理 provider 的档位预算"),
82
+ cacheRetention: z.union([
83
+ "none",
84
+ "short",
85
+ "long"
86
+ ]).description("提示缓存保留偏好"),
87
+ transport: z.union([
88
+ "sse",
89
+ "websocket",
90
+ "websocket-cached",
91
+ "auto"
92
+ ]).description("流式传输偏好"),
93
+ timeoutMs: z.natural().description("HTTP/provider 超时毫秒"),
94
+ websocketConnectTimeoutMs: z.natural().description("WebSocket 连接超时毫秒"),
95
+ streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).description("单支流式读取的最大空闲间隔毫秒"),
96
+ retryPolicy: z.any().description("provider 重试策略(dsh-llm RetryPolicy 形状,构建期校验)"),
97
+ models: z.array(modelEntry).description("本 route 的模型目录;缺省且 provider 有 extends 时继承该源全部模型")
98
+ });
99
+ const Config = z.object({
100
+ enabled: z.boolean().description("总开关(关闭则不注册任何 route)").default(true),
101
+ catalogUrl: z.string().description("models.dev 目录数据端点").default("https://models.dev/api.json"),
102
+ catalogRefreshHours: z.number().description("models.dev 自动拉取间隔小时数;0 = 不自动拉取(可手动拉取或读已有缓存)").default(0),
103
+ catalogProxy: z.string().description("拉取 models.dev 目录时的 HTTP 代理地址(如 http://127.0.0.1:7890);留空直连").default(""),
104
+ providers: z.dict(providerProfile).description("provider 路由表,键即 route 名").default({})
105
+ });
106
+ function toWire(cfg, writable, kitSource, modelsDevStatus) {
107
+ return {
108
+ enabled: cfg.enabled,
109
+ catalogUrl: cfg.catalogUrl,
110
+ catalogRefreshHours: cfg.catalogRefreshHours,
111
+ catalogProxy: cfg.catalogProxy ?? "",
112
+ providers: cfg.providers ?? {},
113
+ writable,
114
+ kitSource,
115
+ modelsDevStatus
116
+ };
117
+ }
118
+ const WirePatch = z.object({
119
+ enabled: z.boolean(),
120
+ catalogUrl: z.string(),
121
+ catalogRefreshHours: z.number(),
122
+ catalogProxy: z.string(),
123
+ providers: z.dict(providerProfile)
124
+ });
125
+
126
+ //#endregion
127
+ //#region src/catalog/builtin.ts
128
+ /** 内置 provider 的端点(provider 级 extends 的 baseURL 缺省值)。 */
129
+ function builtinProviderBaseUrl(kit, provider) {
130
+ return kit.builtinProviders().find((p) => p.id === provider)?.baseUrl;
131
+ }
132
+ /** 内置目录是否存在该 provider。 */
133
+ function hasBuiltinProvider(kit, provider) {
134
+ return kit.getBuiltinProviders().includes(provider);
135
+ }
136
+ /** 内置 provider 的全部模型 id(UI extends 选择器用)。 */
137
+ function builtinModelIds(kit, provider) {
138
+ if (!hasBuiltinProvider(kit, provider)) return [];
139
+ return kit.getBuiltinModels(provider).map((m) => m.id);
140
+ }
141
+ /** 查单个内置模型为继承 base;未命中返回 undefined。 */
142
+ function builtinModelBase(kit, provider, modelId) {
143
+ if (!hasBuiltinProvider(kit, provider)) return void 0;
144
+ const model = kit.getBuiltinModels(provider).find((m) => m.id === modelId);
145
+ if (model === void 0) return void 0;
146
+ return {
147
+ name: model.name,
148
+ api: model.api,
149
+ baseUrl: model.baseUrl,
150
+ input: [...model.input],
151
+ reasoning: model.reasoning,
152
+ ...model.thinkingLevelMap === void 0 ? {} : { thinkingLevelMap: { ...model.thinkingLevelMap } },
153
+ ...model.compat === void 0 ? {} : { compat: { ...model.compat } },
154
+ contextWindow: model.contextWindow,
155
+ maxTokens: model.maxTokens,
156
+ cost: { ...model.cost },
157
+ ...model.headers === void 0 ? {} : { headers: { ...model.headers } }
158
+ };
159
+ }
160
+ /**
161
+ * provider 级 extends 的全量模型继承:route 不写 models 时,
162
+ * 以继承源 provider 的全部内置模型作为条目(每个条目 base 即其自身)。
163
+ */
164
+ function inheritedCatalogEntries(kit, provider) {
165
+ if (!hasBuiltinProvider(kit, provider)) return [];
166
+ return kit.getBuiltinModels(provider).map((model) => ({
167
+ id: model.id,
168
+ base: builtinModelBase(kit, provider, model.id) ?? {}
169
+ }));
170
+ }
171
+
172
+ //#endregion
173
+ //#region src/config-api.ts
174
+ const ROUTE_CONFIG = "/dsh-plus/llm-pi/config";
175
+ const ROUTE_CATALOG = "/dsh-plus/llm-pi/catalog";
176
+ const MAX_BODY_BYTES = 256 * 1024;
177
+ function sendJson(res, status, body) {
178
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
179
+ res.end(JSON.stringify(body));
180
+ }
181
+ function readBody(req) {
182
+ return new Promise((resolve, reject) => {
183
+ const chunks = [];
184
+ let size = 0;
185
+ req.on("data", (chunk) => {
186
+ size += chunk.length;
187
+ if (size > MAX_BODY_BYTES) {
188
+ reject(/* @__PURE__ */ new Error("request body too large"));
189
+ req.destroy();
190
+ return;
191
+ }
192
+ chunks.push(chunk);
193
+ });
194
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
195
+ req.on("error", reject);
196
+ });
197
+ }
198
+ async function readPatch(req) {
199
+ const raw = await readBody(req);
200
+ let parsed;
201
+ try {
202
+ parsed = JSON.parse(raw);
203
+ } catch {
204
+ throw new Error("request body is not valid JSON");
205
+ }
206
+ return WirePatch(parsed);
207
+ }
208
+ function wireOf(runtime, writable) {
209
+ return toWire(runtime.currentConfig(), writable, runtime.kitInfo().source, runtime.modelsDev.status());
210
+ }
211
+ async function handleConfig(ctx, runtime, req, res) {
212
+ const settings = ctx.get("settings");
213
+ if (req.method === "GET") {
214
+ sendJson(res, 200, wireOf(runtime, settings !== void 0));
215
+ return;
216
+ }
217
+ if (req.method !== "PUT") {
218
+ sendJson(res, 405, { error: "method not allowed" });
219
+ return;
220
+ }
221
+ if (settings === void 0) {
222
+ sendJson(res, 503, { error: "settings provider 不可用,无法在线保存;请编辑 settings.yaml" });
223
+ return;
224
+ }
225
+ const patch = await readPatch(req);
226
+ await settings.replace(SETTINGS_NS, patch);
227
+ sendJson(res, 200, wireOf(runtime, true));
228
+ }
229
+ /** 目录查询/手动拉取:GET ?provider=&source= → 该源模型 id 列表;POST /refresh → 立即拉取。 */
230
+ function handleCatalog(runtime, req, res) {
231
+ if (req.method === "POST" && req.url?.endsWith("/refresh")) {
232
+ runtime.modelsDev.refresh().then(() => {
233
+ sendJson(res, 200, { status: runtime.modelsDev.status() });
234
+ });
235
+ return;
236
+ }
237
+ const url = new URL(req.url ?? "", "http://localhost");
238
+ const provider = url.searchParams.get("provider") ?? "";
239
+ if ((url.searchParams.get("source") ?? "builtin") === "models-dev") {
240
+ sendJson(res, 200, {
241
+ providers: runtime.modelsDev.providerIds(),
242
+ models: provider.length > 0 ? runtime.modelsDev.modelIds(provider) : [],
243
+ status: runtime.modelsDev.status()
244
+ });
245
+ return;
246
+ }
247
+ sendJson(res, 200, {
248
+ providers: runtime.kit.getBuiltinProviders(),
249
+ models: provider.length > 0 ? builtinModelIds(runtime.kit, provider) : []
250
+ });
251
+ }
252
+ /** 注册配置读写与目录查询路由(webServer 缺失时由调用方保证不调用)。 */
253
+ function registerConfigApi(ctx, runtime) {
254
+ const logger = ctx.logger("llm-pi");
255
+ const guard = (handler) => {
256
+ return async (req, res) => {
257
+ try {
258
+ await handler(req, res);
259
+ } catch (error) {
260
+ const message = error instanceof Error ? error.message : String(error);
261
+ logger.warn(`config api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
262
+ if (!res.headersSent) sendJson(res, 400, { error: message });
263
+ else res.end();
264
+ }
265
+ };
266
+ };
267
+ ctx.webServer.register({
268
+ kind: "exact",
269
+ path: ROUTE_CONFIG,
270
+ handler: guard((req, res) => handleConfig(ctx, runtime, req, res))
271
+ });
272
+ ctx.webServer.register({
273
+ kind: "prefix",
274
+ path: ROUTE_CATALOG,
275
+ handler: guard((req, res) => handleCatalog(runtime, req, res))
276
+ });
277
+ }
278
+
279
+ //#endregion
280
+ //#region src/catalog/models-dev.ts
281
+ /** 仅 https 目标走代理(http 目标直连;代理通常只提供 CONNECT 隧道)。 */
282
+ const { HttpsProxyAgent } = HttpsProxyAgentModule;
283
+ const FETCH_TIMEOUT_MS = 2e4;
284
+ /** 目录文档体上限(api.json 全量约 1-2MB,放宽到 20MB 防未来膨胀)。 */
285
+ const MAX_RESPONSE_BYTES$1 = 20 * 1024 * 1024;
286
+ /**
287
+ * 极简 JSON GET(node:http(s) 实现):支持 HTTP 代理(仅 https 目标)与超时。
288
+ * 不跟随重定向(models.dev 直链无重定向;自定义端点需自行保证可直达)。
289
+ */
290
+ function fetchJson(url, proxy, timeoutMs) {
291
+ return new Promise((resolve, reject) => {
292
+ const target = new URL(url);
293
+ const req = (target.protocol === "https:" ? request$1 : request)(url, {
294
+ agent: target.protocol === "https:" && proxy.length > 0 ? new HttpsProxyAgent(proxy) : void 0,
295
+ timeout: timeoutMs,
296
+ headers: { accept: "application/json" }
297
+ }, (response) => {
298
+ const chunks = [];
299
+ let size = 0;
300
+ response.on("data", (chunk) => {
301
+ size += chunk.length;
302
+ if (size > MAX_RESPONSE_BYTES$1) {
303
+ req.destroy(/* @__PURE__ */ new Error("响应超过 20MB 上限"));
304
+ return;
305
+ }
306
+ chunks.push(chunk);
307
+ });
308
+ response.on("end", () => {
309
+ resolve({
310
+ status: response.statusCode ?? 0,
311
+ body: Buffer.concat(chunks).toString("utf8")
312
+ });
313
+ });
314
+ });
315
+ req.on("timeout", () => req.destroy(/* @__PURE__ */ new Error(`请求超时(${timeoutMs}ms)`)));
316
+ req.on("error", reject);
317
+ req.end();
318
+ });
319
+ }
320
+ function isDocument(value) {
321
+ return typeof value === "object" && value !== null && !Array.isArray(value);
322
+ }
323
+ /** 单个 models.dev 模型条目 → 继承 base(仅采信名称/容量/推理能力)。 */
324
+ function toModelBase(entry) {
325
+ const base = {};
326
+ if (typeof entry.name === "string" && entry.name.length > 0) base.name = entry.name;
327
+ const context = entry.limit?.context;
328
+ if (typeof context === "number" && Number.isInteger(context) && context > 0) base.contextWindow = context;
329
+ const output = entry.limit?.output;
330
+ if (typeof output === "number" && Number.isInteger(output) && output > 0) base.maxTokens = output;
331
+ base.reasoning = entry.reasoning === true;
332
+ return base;
333
+ }
334
+ var ModelsDevSource = class {
335
+ cacheFile;
336
+ url;
337
+ ttlHours;
338
+ proxy;
339
+ log;
340
+ document;
341
+ fetchedAt = null;
342
+ lastError = null;
343
+ refreshing;
344
+ constructor(cacheFile, url, ttlHours, log, proxy = "") {
345
+ this.cacheFile = cacheFile;
346
+ this.url = url;
347
+ this.ttlHours = ttlHours;
348
+ this.log = log;
349
+ this.proxy = proxy;
350
+ }
351
+ /** 配置变更时更新端点/TTL/代理并触发刷新(去抖由 refresh 的进行中复用承担)。 */
352
+ reconfigure(url, ttlHours, proxy) {
353
+ if (url === this.url && ttlHours === this.ttlHours && proxy === this.proxy) return;
354
+ this.url = url;
355
+ this.ttlHours = ttlHours;
356
+ this.proxy = proxy;
357
+ if (ttlHours > 0 && this.isStale()) this.refresh();
358
+ }
359
+ /** 是否已有可用数据(缓存或拉取成功);与自动拉取开关无关。 */
360
+ get enabled() {
361
+ return this.document !== void 0;
362
+ }
363
+ /** 加载缓存,并在启用自动拉取且缓存过期时后台刷新;构造后调用一次,永不抛错。 */
364
+ async ensureLoaded() {
365
+ this.loadCache();
366
+ if (this.ttlHours > 0 && this.isStale()) await this.refresh();
367
+ }
368
+ /** 强制刷新(手动拉取/配置变更触发);ttlHours=0 时同样生效(手动拉取不受自动开关限制)。 */
369
+ async refresh() {
370
+ this.refreshing ??= this.doFetch().finally(() => {
371
+ this.refreshing = void 0;
372
+ });
373
+ await this.refreshing;
374
+ }
375
+ /** 查继承 base;未命中/未启用返回 undefined。 */
376
+ lookup(provider, modelId) {
377
+ const entry = this.document?.[provider]?.models?.[modelId];
378
+ if (entry === void 0) return void 0;
379
+ return toModelBase(entry);
380
+ }
381
+ /** 全部 provider id(UI extends 选择器用)。 */
382
+ providerIds() {
383
+ return Object.keys(this.document ?? {});
384
+ }
385
+ /** 某 provider 的模型 id 列表(UI extends 选择器用)。 */
386
+ modelIds(provider) {
387
+ return Object.keys(this.document?.[provider]?.models ?? {});
388
+ }
389
+ status() {
390
+ const providers = Object.keys(this.document ?? {});
391
+ const models = providers.reduce((total, p) => total + Object.keys(this.document?.[p]?.models ?? {}).length, 0);
392
+ return {
393
+ fetchedAt: this.fetchedAt,
394
+ providers: providers.length,
395
+ models,
396
+ error: this.lastError
397
+ };
398
+ }
399
+ isStale() {
400
+ if (this.fetchedAt === null) return true;
401
+ const ageMs = Date.now() - Date.parse(this.fetchedAt);
402
+ return !Number.isFinite(ageMs) || ageMs > this.ttlHours * 36e5;
403
+ }
404
+ loadCache() {
405
+ try {
406
+ const raw = JSON.parse(readFileSync(this.cacheFile, "utf8"));
407
+ if (!isDocument(raw.data) || typeof raw.fetchedAt !== "string") throw new Error("缓存形状非法");
408
+ this.document = raw.data;
409
+ this.fetchedAt = raw.fetchedAt;
410
+ } catch {}
411
+ }
412
+ async doFetch() {
413
+ try {
414
+ const response = await fetchJson(this.url, this.proxy, FETCH_TIMEOUT_MS);
415
+ if (response.status < 200 || response.status >= 300) throw new Error(`HTTP ${response.status}`);
416
+ const data = JSON.parse(response.body);
417
+ if (!isDocument(data)) throw new Error("响应不是 models.dev 目录文档");
418
+ this.document = data;
419
+ this.fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
420
+ this.lastError = null;
421
+ this.persistCache(data);
422
+ } catch (error) {
423
+ this.lastError = error instanceof Error ? error.message : String(error);
424
+ this.log(`models.dev 目录拉取失败(沿用缓存/仅内置目录):${this.lastError}`);
425
+ }
426
+ }
427
+ persistCache(data) {
428
+ try {
429
+ mkdirSync(dirname(this.cacheFile), { recursive: true });
430
+ const tmp = `${this.cacheFile}.tmp`;
431
+ writeFileSync(tmp, JSON.stringify({
432
+ fetchedAt: this.fetchedAt,
433
+ data
434
+ }), "utf8");
435
+ renameSync(tmp, this.cacheFile);
436
+ } catch (error) {
437
+ this.log(`models.dev 缓存写入失败:${error instanceof Error ? error.message : String(error)}`);
438
+ }
439
+ }
440
+ };
441
+
442
+ //#endregion
443
+ //#region src/discovery.ts
444
+ const LISTABLE_PROTOCOLS = new Set(["openai-completions", "openai-responses"]);
445
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
446
+ /** 读取有界响应体:声明超长或累计超长都拒绝(对齐官方 readBounded)。 */
447
+ async function readBounded(kit, response, url) {
448
+ const oversized = () => new kit.LlmError(`${url} 响应超过 ${MAX_RESPONSE_BYTES} 字节`, "DISCOVERY_FAILED");
449
+ const declared = Number(response.headers.get("content-length") ?? NaN);
450
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
451
+ await response.body?.cancel();
452
+ throw oversized();
453
+ }
454
+ if (response.body === null) return "";
455
+ const reader = response.body.getReader();
456
+ const chunks = [];
457
+ let total = 0;
458
+ try {
459
+ for (;;) {
460
+ const { done, value } = await reader.read();
461
+ if (done) break;
462
+ total += value.byteLength;
463
+ if (total > MAX_RESPONSE_BYTES) throw oversized();
464
+ chunks.push(value);
465
+ }
466
+ } finally {
467
+ await reader.cancel().catch(() => {});
468
+ }
469
+ const body = new Uint8Array(total);
470
+ let offset = 0;
471
+ for (const chunk of chunks) {
472
+ body.set(chunk, offset);
473
+ offset += chunk.byteLength;
474
+ }
475
+ return new TextDecoder().decode(body);
476
+ }
477
+ /** 解析 OpenAI 兼容模型清单;坏行跳过而非整表失败(对齐官方 readListing)。 */
478
+ function readListing(kit, body) {
479
+ const data = body?.data;
480
+ if (!Array.isArray(data)) throw new kit.LlmError("端点的模型清单缺少 \"data\" 数组;请手工录入模型", "DISCOVERY_FAILED");
481
+ const models = [];
482
+ for (const raw of data) {
483
+ const entry = raw;
484
+ if (typeof entry?.["id"] !== "string" || entry["id"].length === 0) continue;
485
+ const out = { id: entry["id"] };
486
+ const name$1 = entry["name"] ?? entry["display_name"];
487
+ if (typeof name$1 === "string" && name$1.length > 0) out.name = name$1;
488
+ for (const [key, field] of [
489
+ ["context_window", "contextWindow"],
490
+ ["context_length", "contextWindow"],
491
+ ["max_output_tokens", "maxTokens"],
492
+ ["max_tokens", "maxTokens"]
493
+ ]) {
494
+ const value = entry[key];
495
+ if (typeof value === "number" && Number.isInteger(value) && value > 0 && out[field] === void 0) out[field] = value;
496
+ }
497
+ models.push(out);
498
+ }
499
+ return models;
500
+ }
501
+ /** 内置目录直答(route 配了 provider 级 extends 时)。 */
502
+ function catalogAnswer(kit, source) {
503
+ return builtinModelIds(kit, source).map((id) => {
504
+ const model = kit.getBuiltinModels(source).find((m) => m.id === id);
505
+ return {
506
+ id,
507
+ ...model?.name === void 0 ? {} : { name: model.name },
508
+ ...model?.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
509
+ ...model?.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
510
+ };
511
+ });
512
+ }
513
+ /**
514
+ * 回答"该 provider 可服务哪些模型":extends 内置源零网络直答;
515
+ * 否则仅 openai 系协议走 GET {baseURL}/models;其余协议明确不支持。
516
+ */
517
+ async function discoverModels(request$2, deps) {
518
+ const { kit } = deps;
519
+ const route = request$2.provider === void 0 ? void 0 : deps.configProviders()[request$2.provider];
520
+ if (route?.extends !== void 0 && hasBuiltinProvider(kit, route.extends)) return catalogAnswer(kit, route.extends);
521
+ const baseURL = request$2.baseURL ?? route?.baseURL;
522
+ if (baseURL === void 0 || baseURL.length === 0) throw new kit.LlmError(`route ${JSON.stringify(request$2.provider ?? "")} 未配 baseURL 且 extends 源无内置目录;无法探测模型清单`, "DISCOVERY_FAILED");
523
+ const api = request$2.api ?? route?.api ?? "openai-completions";
524
+ if (!LISTABLE_PROTOCOLS.has(api)) throw new kit.LlmError(`协议 "${api}" 无可读取的模型清单端点;请手工录入模型`, "DISCOVERY_UNSUPPORTED");
525
+ const url = `${baseURL.replace(/\/+$/, "")}/models`;
526
+ const supplied = request$2.apiKey ?? await deps.storedApiKey(request$2.provider);
527
+ let authorization;
528
+ if (supplied !== void 0) {
529
+ const checked = kit.normalizeApiKey(supplied);
530
+ if (!checked.ok) throw new kit.LlmError(checked.reason === "empty" ? "API key 为空;请在 Models 页配置或留空以匿名探测" : "API key 含有 HTTP 头无法携带的字符", kit.INVALID_CREDENTIAL_CODE);
531
+ authorization = `Bearer ${checked.value}`;
532
+ }
533
+ let response;
534
+ try {
535
+ response = await fetch(url, {
536
+ method: "GET",
537
+ headers: {
538
+ accept: "application/json",
539
+ ...authorization === void 0 ? {} : { authorization },
540
+ ...kit.attributionHeaders()
541
+ },
542
+ ...request$2.signal === void 0 ? {} : { signal: request$2.signal }
543
+ });
544
+ } catch (error) {
545
+ if (request$2.signal?.aborted) throw new kit.LlmError("模型发现被调用方中止", "ABORTED", { cause: error });
546
+ throw new kit.LlmError(`无法连接 ${url}`, "DISCOVERY_FAILED", { cause: error });
547
+ }
548
+ if (!response.ok) throw new kit.LlmError(`${url} 返回 ${response.status}${response.status === 401 || response.status === 403 ? ";请检查 API key" : ""}`, "DISCOVERY_FAILED");
549
+ const text = await readBounded(kit, response, url);
550
+ try {
551
+ return readListing(kit, JSON.parse(text));
552
+ } catch (error) {
553
+ if (error instanceof kit.LlmError) throw error;
554
+ throw new kit.LlmError(`${url} 未返回 JSON`, "DISCOVERY_FAILED", { cause: error });
555
+ }
556
+ }
557
+
558
+ //#endregion
559
+ //#region src/compat.ts
560
+ /** openai-completions 的 21 个字段(pi-ai OpenAICompletionsCompat)。 */
561
+ const COMPLETIONS_FIELDS = {
562
+ supportsStore: "boolean",
563
+ supportsDeveloperRole: "boolean",
564
+ supportsReasoningEffort: "boolean",
565
+ supportsUsageInStreaming: "boolean",
566
+ maxTokensField: ["max_completion_tokens", "max_tokens"],
567
+ requiresToolResultName: "boolean",
568
+ requiresAssistantAfterToolResult: "boolean",
569
+ requiresThinkingAsText: "boolean",
570
+ requiresReasoningContentOnAssistantMessages: "boolean",
571
+ thinkingFormat: [
572
+ "openai",
573
+ "openrouter",
574
+ "deepseek",
575
+ "together",
576
+ "zai",
577
+ "qwen",
578
+ "chat-template",
579
+ "qwen-chat-template",
580
+ "string-thinking",
581
+ "ant-ling"
582
+ ],
583
+ chatTemplateKwargs: "object",
584
+ openRouterRouting: "object",
585
+ vercelGatewayRouting: "object",
586
+ zaiToolStream: "boolean",
587
+ supportsOpenAIGrammarTools: "boolean",
588
+ supportsStrictMode: "boolean",
589
+ cacheControlFormat: ["anthropic"],
590
+ sendSessionAffinityHeaders: "boolean",
591
+ deferredToolsMode: ["kimi"],
592
+ sessionAffinityFormat: [
593
+ "openai",
594
+ "openai-nosession",
595
+ "openrouter"
596
+ ],
597
+ supportsLongCacheRetention: "boolean"
598
+ };
599
+ /** openai-responses 的 7 个字段(pi-ai OpenAIResponsesCompat)。 */
600
+ const RESPONSES_FIELDS = {
601
+ supportsDeveloperRole: "boolean",
602
+ sessionAffinityFormat: [
603
+ "openai",
604
+ "openai-nosession",
605
+ "openrouter"
606
+ ],
607
+ supportsLongCacheRetention: "boolean",
608
+ supportsStrictMode: "boolean",
609
+ supportsOpenAIGrammarTools: "boolean",
610
+ supportsToolSearch: "boolean",
611
+ supportsExplicitPromptCacheMode: "boolean"
612
+ };
613
+ /** anthropic-messages 的 9 个字段(pi-ai AnthropicMessagesCompat)。 */
614
+ const ANTHROPIC_FIELDS = {
615
+ supportsEagerToolInputStreaming: "boolean",
616
+ supportsLongCacheRetention: "boolean",
617
+ sendSessionAffinityHeaders: "boolean",
618
+ supportsCacheControlOnTools: "boolean",
619
+ supportsTemperature: "boolean",
620
+ forceAdaptiveThinking: "boolean",
621
+ allowEmptySignature: "boolean",
622
+ supportsStrictTools: "boolean",
623
+ supportsToolReferences: "boolean"
624
+ };
625
+ const FIELDS_BY_PROTOCOL = {
626
+ "openai-completions": COMPLETIONS_FIELDS,
627
+ "openai-responses": RESPONSES_FIELDS,
628
+ "anthropic-messages": ANTHROPIC_FIELDS
629
+ };
630
+ function checkValue(api, field, spec, value, where) {
631
+ if (spec === "boolean") {
632
+ if (typeof value !== "boolean") throw new Error(`${where}: compat.${field} 必须是布尔值`);
633
+ return;
634
+ }
635
+ if (spec === "object") {
636
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${where}: compat.${field} 必须是对象`);
637
+ return;
638
+ }
639
+ if (typeof value !== "string" || !spec.includes(value)) throw new Error(`${where}: compat.${field} 必须是 ${spec.map((v) => JSON.stringify(v)).join(" | ")} 之一`);
640
+ }
641
+ /**
642
+ * 校验一份 compat 字典对指定协议合法:未知协议/未知键拒绝(对比官方的静默丢弃),
643
+ * 已知键校验值类型/枚举。undefined 值视为未设置,跳过(语义同 pi-ai 的 ??)。
644
+ */
645
+ function validateCompat(api, compat, where) {
646
+ if (compat === void 0) return;
647
+ const fields = FIELDS_BY_PROTOCOL[api];
648
+ if (fields === void 0) throw new Error(`${where}: 协议 ${JSON.stringify(api)} 无 compat 字段表(支持:${Object.keys(FIELDS_BY_PROTOCOL).join(", ")})`);
649
+ for (const [key, value] of Object.entries(compat)) {
650
+ const spec = fields[key];
651
+ if (spec === void 0) throw new Error(`${where}: compat.${key} 不是 ${api} 协议的合法字段(合法字段:${Object.keys(fields).join(", ")})`);
652
+ if (value === void 0) continue;
653
+ checkValue(api, key, spec, value, where);
654
+ }
655
+ }
656
+ /**
657
+ * 逐字段合并 compat 层(后者覆盖前者),丢弃 undefined 值。
658
+ * 层序:继承源(仅同协议)→ route 级 → 模型级。
659
+ */
660
+ function mergeCompat(...layers) {
661
+ const merged = {};
662
+ for (const layer of layers) {
663
+ if (layer === void 0) continue;
664
+ for (const [key, value] of Object.entries(layer)) if (value !== void 0) merged[key] = value;
665
+ }
666
+ return Object.keys(merged).length > 0 ? merged : void 0;
667
+ }
668
+
669
+ //#endregion
670
+ //#region src/inherit.ts
671
+ var ExtendsError = class extends Error {};
672
+ /** 解析 "provider/model" 或裸 "model" 引用。 */
673
+ function parseExtendsRef(raw) {
674
+ const slash = raw.indexOf("/");
675
+ if (slash < 0) return { model: raw };
676
+ const provider = raw.slice(0, slash);
677
+ const model = raw.slice(slash + 1);
678
+ if (provider.length === 0 || model.length === 0 || model.includes("/")) throw new ExtendsError(`extends 引用 ${JSON.stringify(raw)} 非法:应为 "provider/model" 或 "model"`);
679
+ return {
680
+ provider,
681
+ model
682
+ };
683
+ }
684
+ function lookup(kit, modelsDev, provider, model) {
685
+ const builtin = builtinModelBase(kit, provider, model);
686
+ if (builtin !== void 0) return {
687
+ base: builtin,
688
+ source: "builtin",
689
+ sourceProvider: provider
690
+ };
691
+ const dev = modelsDev?.lookup(provider, model);
692
+ if (dev !== void 0) return {
693
+ base: dev,
694
+ source: "models-dev",
695
+ sourceProvider: provider
696
+ };
697
+ }
698
+ /**
699
+ * 解析一个模型条目的继承 base。
700
+ * @throws ExtendsError 显式 extends 引用不存在(写入时拒绝,指明引用名)。
701
+ */
702
+ function resolveModelBase(route, profile, entry, kit, modelsDev) {
703
+ const where = `provider "${route}" model "${entry.id}"`;
704
+ if (entry.extends === void 0) {
705
+ if (profile.extends === void 0) return {
706
+ base: {},
707
+ source: "none"
708
+ };
709
+ return lookup(kit, modelsDev, profile.extends, entry.id) ?? {
710
+ base: {},
711
+ source: "none"
712
+ };
713
+ }
714
+ const ref = parseExtendsRef(entry.extends);
715
+ const provider = ref.provider ?? profile.extends;
716
+ if (provider === void 0) throw new ExtendsError(`${where}: extends ${JSON.stringify(entry.extends)} 是裸模型 id,但本 route 未配置 provider 级 extends 查找源`);
717
+ const hit = lookup(kit, modelsDev, provider, ref.model);
718
+ if (hit === void 0) throw new ExtendsError(`${where}: extends 引用 "${provider}/${ref.model}" 在内置目录与 models.dev 快照中都不存在`);
719
+ return hit;
720
+ }
721
+
722
+ //#endregion
723
+ //#region src/profiles.ts
724
+ /** 内置目录未描述时的零价目(harness 不消费 cost 元数据,同官方 NO_COST)。 */
725
+ const NO_COST = {
726
+ input: 0,
727
+ output: 0,
728
+ cacheRead: 0,
729
+ cacheWrite: 0
730
+ };
731
+ /** 报告不可服务的 route,命名出错配置键(对齐官方 invalid())。 */
732
+ function invalid(provider, detail) {
733
+ throw new Error(`llm-pi: provider "${provider}" ${detail}`);
734
+ }
735
+ /** 条目的声明模态;缺省/空数组都视为"无答案",交下一级(同官方 declaredInput)。 */
736
+ function declaredInput(configured) {
737
+ return configured === void 0 || configured.length === 0 ? void 0 : [...configured];
738
+ }
739
+ /**
740
+ * 单个模型的 reasoning 物化(逐行对齐官方 resolveModelReasoning):
741
+ * 显式 dict → 全档位确定的 thinkingLevelMap(未声明档位置 null);
742
+ * false → 非推理模型;缺省 → 保留继承源的 reasoning 能力。
743
+ */
744
+ function resolveModelReasoning(provider, entry, base) {
745
+ const efforts = entry.reasoningEfforts;
746
+ if (efforts === void 0) {
747
+ if (base.reasoning === void 0) return { reasoning: false };
748
+ return {
749
+ reasoning: base.reasoning,
750
+ ...base.thinkingLevelMap === void 0 ? {} : { thinkingLevelMap: base.thinkingLevelMap }
751
+ };
752
+ }
753
+ if (efforts === false) return { reasoning: false };
754
+ if (Object.keys(efforts).length === 0) invalid(provider, `model "${entry.id}" 的 reasoningEfforts 为空:声明档位、置 false 或缺省继承`);
755
+ for (const level of THINKING_LEVELS) {
756
+ const wire = efforts[level];
757
+ if (wire === void 0) continue;
758
+ if (wire === null) {
759
+ if (level !== "off") invalid(provider, `model "${entry.id}" reasoningEfforts.${level} 需要线值;仅 off 可留空`);
760
+ } else if (wire.length === 0) invalid(provider, `model "${entry.id}" reasoningEfforts.${level} 不能为空字符串`);
761
+ }
762
+ if (!THINKING_LEVELS.filter((level) => efforts[level] !== void 0).some((level) => level !== "off")) invalid(provider, `model "${entry.id}" reasoningEfforts 只有 off;声明思考档位或置 false`);
763
+ const map = {};
764
+ for (const level of THINKING_LEVELS) {
765
+ const wire = efforts[level];
766
+ if (wire === void 0) map[level] = null;
767
+ else if (wire !== null) map[level] = wire;
768
+ }
769
+ return {
770
+ reasoning: true,
771
+ thinkingLevelMap: map
772
+ };
773
+ }
774
+ /** Harness 自管凭据的 api-key auth(对齐官方 harnessApiKeyAuth,index.js:1215)。 */
775
+ function harnessApiKeyAuth(name$1) {
776
+ return {
777
+ name: name$1,
778
+ resolve: ({ credential }) => Promise.resolve({
779
+ auth: credential?.key === void 0 ? {} : { apiKey: credential.key },
780
+ source: name$1
781
+ })
782
+ };
783
+ }
784
+ /** 物化单个模型:继承 base 在下,条目显式字段逐字段覆盖。lenient 下缺 api/baseURL 时跳过(返回 null)。 */
785
+ function materializeModel(route, profile, entry, base, routeApi, providerBaseUrl, defaultInput, deps, configuredMaxTokens) {
786
+ const api = profile.api ?? base.api ?? routeApi;
787
+ if (api === void 0) {
788
+ if (deps.lenient) {
789
+ deps.warn?.(`llm-pi: provider "${route}" model "${entry.id}" 无法获得 api(继承源缺失),已跳过该模型`);
790
+ return null;
791
+ }
792
+ invalid(route, `model "${entry.id}" 需要 api:继承源未提供,请在 route 上设置 api`);
793
+ }
794
+ const baseUrl = profile.baseURL ?? base.baseUrl ?? providerBaseUrl;
795
+ if (baseUrl === void 0) {
796
+ if (deps.lenient) {
797
+ deps.warn?.(`llm-pi: provider "${route}" model "${entry.id}" 无法获得 baseURL(继承源缺失),已跳过该模型`);
798
+ return null;
799
+ }
800
+ invalid(route, `model "${entry.id}" 需要 baseURL:继承源未提供,请在 route 上设置 baseURL`);
801
+ }
802
+ const contextWindow = entry.contextWindow ?? base.contextWindow ?? profile.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW;
803
+ const maxTokens = entry.maxTokens ?? base.maxTokens ?? profile.defaultMaxTokens ?? DEFAULT_MAX_TOKENS;
804
+ if (entry.maxTokens !== void 0) configuredMaxTokens.set(entry.id, entry.maxTokens);
805
+ validateCompat(api, profile.compat, `provider "${route}"`);
806
+ validateCompat(api, entry.compat, `provider "${route}" model "${entry.id}"`);
807
+ const compat = mergeCompat(base.api === api ? base.compat : void 0, profile.compat, entry.compat);
808
+ return {
809
+ id: entry.id,
810
+ name: entry.name ?? base.name ?? entry.id,
811
+ api,
812
+ provider: route,
813
+ baseUrl,
814
+ ...resolveModelReasoning(route, entry, base),
815
+ input: declaredInput(entry.input) ?? base.input ?? [...defaultInput],
816
+ cost: base.cost ?? NO_COST,
817
+ contextWindow,
818
+ maxTokens,
819
+ ...base.headers === void 0 ? {} : { headers: { ...base.headers } },
820
+ ...compat === void 0 ? {} : { compat }
821
+ };
822
+ }
823
+ /** 物化一个 route 的全部模型;返回模型列表与显式配置的请求 cap 表。
824
+ * lenient 下 route 无任何可服务模型时返回 null(调用方跳过该 route)。 */
825
+ function materializeRouteModels(route, profile, deps, defaultInput) {
826
+ const configuredMaxTokens = /* @__PURE__ */ new Map();
827
+ const providerBaseUrl = profile.extends === void 0 ? void 0 : builtinProviderBaseUrl(deps.kit, profile.extends);
828
+ const entries = [];
829
+ if (profile.models !== void 0 && profile.models.length > 0) {
830
+ const seen = /* @__PURE__ */ new Set();
831
+ for (const entry of profile.models) {
832
+ if (entry.id.length === 0) invalid(route, "存在空 id 的模型条目");
833
+ if (seen.has(entry.id)) invalid(route, `模型 "${entry.id}" 重复列出`);
834
+ seen.add(entry.id);
835
+ let base;
836
+ try {
837
+ base = resolveModelBase(route, profile, entry, deps.kit, deps.modelsDev).base;
838
+ } catch (error) {
839
+ if (!deps.lenient || !(error instanceof ExtendsError)) throw error;
840
+ deps.warn?.(`llm-pi: provider "${route}" model "${entry.id}" 的 extends 引用当前不可解析(${error.message});已降级为手写条目`);
841
+ base = {};
842
+ }
843
+ entries.push({
844
+ id: entry.id,
845
+ entry,
846
+ base
847
+ });
848
+ }
849
+ } else if (profile.extends !== void 0) {
850
+ for (const { id, base } of inheritedCatalogEntries(deps.kit, profile.extends)) entries.push({
851
+ id,
852
+ base
853
+ });
854
+ if (entries.length === 0) invalid(route, `extends 源 "${profile.extends}" 在内置目录中没有模型;请显式列出 models`);
855
+ } else invalid(route, "未配置 models 且未配置 provider 级 extends;本 route 无模型可服务");
856
+ const apis = new Set(entries.map(({ base }) => profile.api ?? base.api).filter((api) => api !== void 0));
857
+ const routeApi = apis.size === 1 ? [...apis][0] : void 0;
858
+ const models = entries.map(({ id, entry, base }) => materializeModel(route, profile, entry ?? { id }, base, routeApi, providerBaseUrl, defaultInput, deps, configuredMaxTokens)).filter((model) => model !== null);
859
+ if (models.length === 0) {
860
+ if (deps.lenient) {
861
+ deps.warn?.(`llm-pi: provider "${route}" 当前没有可服务的模型(继承源漂移),已跳过该 route 的注册`);
862
+ return null;
863
+ }
864
+ invalid(route, "route 内没有可服务的模型");
865
+ }
866
+ const finalApis = new Set(models.map((m) => m.api));
867
+ if (finalApis.size > 1) invalid(route, `route 内模型协议不一致(${[...finalApis].join(", ")});一个 route 只能服务一种协议`);
868
+ return {
869
+ models,
870
+ configuredMaxTokens
871
+ };
872
+ }
873
+ /**
874
+ * 校验并物化全部 route。任一 route 不可服务即整体抛错——
875
+ * settings 写入校验与运行期 profiles 回调共用本函数,
876
+ * 保证"写入时被拒"与"运行期不可能拿到坏配置"互为表里。
877
+ */
878
+ function buildProfiles(providers, deps) {
879
+ const resolved = /* @__PURE__ */ new Map();
880
+ for (const [route, profile] of Object.entries(providers ?? {})) {
881
+ if (route.length === 0) throw new Error("llm-pi: provider 名不能为空");
882
+ if (profile.baseURL !== void 0 && profile.baseURL.length === 0) invalid(route, "baseURL 为空");
883
+ if (profile.displayName !== void 0 && profile.displayName.length === 0) invalid(route, "displayName 为空");
884
+ const streamIdleTimeoutMs = profile.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
885
+ if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) invalid(route, `streamIdleTimeoutMs 必须是 (0, ${MAX_TIMER_DELAY_MS}] 内的有限数`);
886
+ const defaultInput = [...profile.defaultInput ?? ["text"]];
887
+ if (defaultInput.length === 0) invalid(route, "defaultInput 至少要声明一种模态");
888
+ const displayName = profile.displayName ?? route;
889
+ const catalog = materializeRouteModels(route, profile, deps, defaultInput);
890
+ if (catalog === null) continue;
891
+ const api = catalog.models[0]?.api;
892
+ const factory = api === void 0 ? void 0 : deps.kit.protocolFactories[api];
893
+ if (factory === void 0) invalid(route, `api ${JSON.stringify(api)} 本插件无法服务(支持:openai-completions/openai-responses/anthropic-messages)`);
894
+ const piProvider = deps.kit.createProvider({
895
+ id: route,
896
+ name: displayName,
897
+ ...profile.baseURL === void 0 ? profile.extends === void 0 ? {} : { baseUrl: builtinProviderBaseUrl(deps.kit, profile.extends) } : { baseUrl: profile.baseURL },
898
+ ...profile.headers === void 0 ? {} : { headers: { ...profile.headers } },
899
+ auth: { apiKey: harnessApiKeyAuth(displayName) },
900
+ models: catalog.models,
901
+ api: factory()
902
+ });
903
+ resolved.set(route, {
904
+ provider: route,
905
+ displayName,
906
+ ...profile.apiKeyEnv === void 0 ? {} : { apiKeyEnv: credentialRef(profile.apiKeyEnv) },
907
+ streamIdleTimeoutMs,
908
+ retryPolicy: deps.kit.resolveRetryPolicy(profile.retryPolicy, `llm-pi: provider "${route}" retryPolicy`),
909
+ ...profile.headers === void 0 ? {} : { headers: { ...profile.headers } },
910
+ ...profile.reasoning === void 0 ? {} : { reasoning: profile.reasoning },
911
+ ...profile.thinkingBudgets === void 0 ? {} : { thinkingBudgets: { ...profile.thinkingBudgets } },
912
+ ...profile.cacheRetention === void 0 ? {} : { cacheRetention: profile.cacheRetention },
913
+ ...profile.transport === void 0 ? {} : { transport: profile.transport },
914
+ ...profile.timeoutMs === void 0 ? {} : { timeoutMs: profile.timeoutMs },
915
+ ...profile.websocketConnectTimeoutMs === void 0 ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs },
916
+ configuredMaxTokens: catalog.configuredMaxTokens,
917
+ piProvider
918
+ });
919
+ }
920
+ return resolved;
921
+ }
922
+ /** settings 写入校验钩子:完整试跑解析,非法配置在写入处拒绝。 */
923
+ function assertServiceable(config, deps) {
924
+ buildProfiles(config.providers, deps);
925
+ }
926
+
927
+ //#endregion
928
+ //#region src/resolve-dsh.ts
929
+ /** kit 必备形状清单:缺失即视为上游不兼容。 */
930
+ function assertKitShape(kit, origin) {
931
+ const problems = [];
932
+ if (typeof kit.PiAiAdapter !== "function") problems.push("PiAiAdapter 不是类");
933
+ else for (const method of [
934
+ "current",
935
+ "stream",
936
+ "listModels",
937
+ "resolveModel",
938
+ "providerInfo"
939
+ ]) if (typeof kit.PiAiAdapter.prototype[method] !== "function") problems.push(`PiAiAdapter.prototype.${method} 缺失`);
940
+ if (typeof kit.createProvider !== "function") problems.push("pi-ai createProvider 缺失");
941
+ if (typeof kit.getBuiltinModels !== "function") problems.push("pi-ai getBuiltinModels 缺失");
942
+ if (typeof kit.builtinProviders !== "function") problems.push("pi-ai builtinProviders 缺失");
943
+ for (const [api, factory] of Object.entries(kit.protocolFactories)) if (typeof factory !== "function") problems.push(`协议工厂 ${api} 缺失`);
944
+ if (typeof kit.LlmError !== "function") problems.push("dsh-llm LlmError 缺失");
945
+ if (typeof kit.resolveRetryPolicy !== "function") problems.push("dsh-llm resolveRetryPolicy 缺失");
946
+ if (problems.length > 0) throw new Error(`llm-pi: ${origin} 来源的运行时套件形状不兼容:${problems.join(";")}`);
947
+ }
948
+ /** 从 startDir 向上找同时含有 dsh-llm-pi-ai 与 pi-ai 的安装树根。 */
949
+ function findDshTreeRoot(startDir) {
950
+ let dir = startDir;
951
+ for (let depth = 0; depth < 8; depth += 1) {
952
+ const nm = join(dir, "node_modules");
953
+ if (existsSync(join(nm, "@deepseek-ai", "dsh-llm-pi-ai", "lib", "index.js")) && existsSync(join(nm, "@earendil-works", "pi-ai", "dist", "index.js"))) return dir;
954
+ const parent = dirname(dir);
955
+ if (parent === dir) return void 0;
956
+ dir = parent;
957
+ }
958
+ }
959
+ /** 从 dsh 安装树按文件路径动态 import 全部套件模块(与官方插件同实例)。 */
960
+ async function importTreeModules(root) {
961
+ const nm = join(root, "node_modules");
962
+ const load = (absPath) => import(pathToFileURL(absPath).href);
963
+ const [piAiAdapter, llm, piAi, catalog, completions, responses, anthropic] = await Promise.all([
964
+ load(join(nm, "@deepseek-ai", "dsh-llm-pi-ai", "lib", "index.js")),
965
+ load(join(nm, "@deepseek-ai", "dsh-llm", "lib", "index.js")),
966
+ load(join(nm, "@earendil-works", "pi-ai", "dist", "index.js")),
967
+ load(join(nm, "@earendil-works", "pi-ai", "dist", "providers", "all.js")),
968
+ load(join(nm, "@earendil-works", "pi-ai", "dist", "api", "openai-completions.lazy.js")),
969
+ load(join(nm, "@earendil-works", "pi-ai", "dist", "api", "openai-responses.lazy.js")),
970
+ load(join(nm, "@earendil-works", "pi-ai", "dist", "api", "anthropic-messages.lazy.js"))
971
+ ]);
972
+ return {
973
+ piAiAdapter,
974
+ llm,
975
+ piAi,
976
+ catalog,
977
+ completions,
978
+ responses,
979
+ anthropic
980
+ };
981
+ }
982
+ function kitFromTree(mods) {
983
+ return {
984
+ source: "dsh-tree",
985
+ PiAiAdapter: mods.piAiAdapter["PiAiAdapter"],
986
+ LlmError: mods.llm["LlmError"],
987
+ resolveRetryPolicy: mods.llm["resolveRetryPolicy"],
988
+ attributionHeaders: mods.llm["attributionHeaders"],
989
+ normalizeApiKey: mods.llm["normalizeApiKey"],
990
+ assertUsableApiKey: mods.llm["assertUsableApiKey"],
991
+ INVALID_CREDENTIAL_CODE: mods.llm["INVALID_CREDENTIAL_CODE"],
992
+ createProvider: mods.piAi["createProvider"],
993
+ builtinProviders: mods.catalog["builtinProviders"],
994
+ getBuiltinProviders: mods.catalog["getBuiltinProviders"],
995
+ getBuiltinModels: mods.catalog["getBuiltinModels"],
996
+ protocolFactories: {
997
+ "openai-completions": mods.completions["openAICompletionsApi"],
998
+ "openai-responses": mods.responses["openAIResponsesApi"],
999
+ "anthropic-messages": mods.anthropic["anthropicMessagesApi"]
1000
+ }
1001
+ };
1002
+ }
1003
+ /** vendored 兜底副本套件(导出供单测直接使用,免走 dsh 树解析)。 */
1004
+ function loadVendoredKit() {
1005
+ const kit = {
1006
+ source: "vendored",
1007
+ PiAiAdapter: vendoredPiAiAdapter.PiAiAdapter,
1008
+ LlmError: vendoredLlm.LlmError,
1009
+ resolveRetryPolicy: vendoredLlm.resolveRetryPolicy,
1010
+ attributionHeaders: vendoredLlm.attributionHeaders,
1011
+ normalizeApiKey: vendoredLlm.normalizeApiKey,
1012
+ assertUsableApiKey: vendoredLlm.assertUsableApiKey,
1013
+ INVALID_CREDENTIAL_CODE: vendoredLlm.INVALID_CREDENTIAL_CODE,
1014
+ createProvider: vendoredPiAi.createProvider,
1015
+ builtinProviders: vendoredCatalog.builtinProviders,
1016
+ getBuiltinProviders: vendoredCatalog.getBuiltinProviders,
1017
+ getBuiltinModels: vendoredCatalog.getBuiltinModels,
1018
+ protocolFactories: {
1019
+ "openai-completions": openAICompletionsApi,
1020
+ "openai-responses": openAIResponsesApi,
1021
+ "anthropic-messages": anthropicMessagesApi
1022
+ }
1023
+ };
1024
+ assertKitShape(kit, "vendored");
1025
+ return kit;
1026
+ }
1027
+ /** 定位 dsh 安装树根:realpath(argv[1]) 向上查找;argv 异常时返回 undefined。 */
1028
+ function dshTreeAnchor() {
1029
+ const entry = process.argv[1];
1030
+ if (entry === void 0) return void 0;
1031
+ try {
1032
+ return findDshTreeRoot(dirname(realpathSync(entry)));
1033
+ } catch {
1034
+ return;
1035
+ }
1036
+ }
1037
+ /**
1038
+ * 解析运行时套件:优先 dsh 安装树(自动跟随上游),失败回退 vendored 副本;
1039
+ * 两者都过不了形状自检时抛错(调用方应记日志并放弃注册 route)。
1040
+ * 返回的 diagnostics 记录回退原因,供配置卡片与日志展示。
1041
+ */
1042
+ async function resolveDshKit() {
1043
+ const diagnostics = [];
1044
+ const anchor = dshTreeAnchor();
1045
+ if (anchor !== void 0) try {
1046
+ const kit = kitFromTree(await importTreeModules(anchor));
1047
+ assertKitShape(kit, "dsh-tree");
1048
+ return {
1049
+ kit,
1050
+ diagnostics
1051
+ };
1052
+ } catch (error) {
1053
+ diagnostics.push(`dsh 安装树套件不可用(${anchor}):${error instanceof Error ? error.message : String(error)};回退 vendored 副本`);
1054
+ }
1055
+ else diagnostics.push("未能从 process.argv[1] 定位 dsh 安装树;回退 vendored 副本");
1056
+ return {
1057
+ kit: loadVendoredKit(),
1058
+ diagnostics
1059
+ };
1060
+ }
1061
+
1062
+ //#endregion
1063
+ //#region src/service.ts
1064
+ /** 注册时捕获的事实表;变化才重注册(按 provider 排序,免序误报)。 */
1065
+ function registrationFacts(profiles) {
1066
+ return [...profiles.entries()].map(([provider, profile]) => ({
1067
+ provider,
1068
+ displayName: profile.displayName,
1069
+ retryPolicy: profile.retryPolicy
1070
+ })).sort((left, right) => left.provider.localeCompare(right.provider));
1071
+ }
1072
+ /** 凭据解析(逐行对齐官方 resolveApiKey):凭据服务优先,缺失时启动环境兜底。 */
1073
+ function makeResolveApiKey(ctx, kit) {
1074
+ return async (provider, profile) => {
1075
+ const ref = profile.apiKeyEnv;
1076
+ if (ref === void 0) return void 0;
1077
+ const credentials = ctx.get("credentials");
1078
+ const hit = credentials !== void 0 ? (await credentials.resolve(ref))?.value : launchEnvironmentOf(ctx).get(ref)?.value;
1079
+ if (hit !== void 0 && hit.length > 0) return kit.assertUsableApiKey(hit, "llm-pi", ref);
1080
+ throw new kit.LlmError(`llm-pi: provider route "${provider}" 的凭据引用 ${String(ref)} 未解析到值——请经凭据服务(web Models 页)存放或导出环境变量;仅当该 provider 应使用 pi-ai 自有环境发现时才移除 apiKeyEnv`, "MISSING_CREDENTIAL");
1081
+ };
1082
+ }
1083
+ /** 启动插件运行时:解析套件、挂载注册/发现/settings 联动。 */
1084
+ async function startRuntime(ctx, rawConfig) {
1085
+ const logger = ctx.logger("llm-pi");
1086
+ const config = Config(rawConfig ?? {});
1087
+ const { kit, diagnostics } = await resolveDshKit();
1088
+ for (const line of diagnostics) logger.warn(line);
1089
+ logger.info(`运行时套件来源:${kit.source}`);
1090
+ const modelsDev = new ModelsDevSource(dshHomePath("storages", "dsh-plus-llm-pi", "models-dev.json"), config.catalogUrl, config.catalogRefreshHours, (message) => logger.warn(message), config.catalogProxy ?? "");
1091
+ modelsDev.ensureLoaded();
1092
+ let current = () => config;
1093
+ let lastRaw;
1094
+ let memoized;
1095
+ const deps = {
1096
+ kit,
1097
+ modelsDev
1098
+ };
1099
+ /** 当前已解析 profiles,按原始 config identity 备忘(官方同款模式)。
1100
+ * 运行期走 lenient:数据源漂移时降级/跳过并告警,而非抛错弄挂整个 route。 */
1101
+ const profiles = () => {
1102
+ const raw = current();
1103
+ if (raw === lastRaw && memoized !== void 0) return memoized;
1104
+ const next = raw.enabled ? buildProfiles(raw.providers, {
1105
+ ...deps,
1106
+ lenient: true,
1107
+ warn: (message) => logger.warn(message)
1108
+ }) : /* @__PURE__ */ new Map();
1109
+ lastRaw = raw;
1110
+ memoized = next;
1111
+ return next;
1112
+ };
1113
+ profiles();
1114
+ const adapter = new kit.PiAiAdapter({
1115
+ profiles,
1116
+ resolveApiKey: makeResolveApiKey(ctx, kit),
1117
+ resolveAttachments: () => ctx.get("attachments")
1118
+ });
1119
+ const storedApiKey = async (provider) => {
1120
+ if (provider === void 0) return void 0;
1121
+ const profile = profiles().get(provider);
1122
+ if (profile === void 0) return void 0;
1123
+ return makeResolveApiKey(ctx, kit)(provider, profile);
1124
+ };
1125
+ ctx.llm.registerModelDiscovery(SETTINGS_NS, (request$2) => discoverModels(request$2, {
1126
+ kit,
1127
+ configProviders: () => current().providers ?? {},
1128
+ storedApiKey
1129
+ }));
1130
+ let registrations;
1131
+ let registeredFacts;
1132
+ const registerGroup = (routes, fallback) => {
1133
+ try {
1134
+ return [{
1135
+ routes,
1136
+ handle: ctx.llm.registerAdapter(routes, adapter)
1137
+ }];
1138
+ } catch (error) {
1139
+ fallback(error);
1140
+ const groups = [];
1141
+ for (const route of routes) try {
1142
+ const handle = ctx.llm.registerAdapter([route], adapter);
1143
+ groups.push({
1144
+ routes: [route],
1145
+ handle
1146
+ });
1147
+ } catch (routeError) {
1148
+ logger.error(`llm-pi: route "${route}" 注册失败(可能与其他 adapter 重名),该 route 不可用`);
1149
+ logger.error(routeError);
1150
+ }
1151
+ return groups;
1152
+ }
1153
+ };
1154
+ const ensureRegistration = () => {
1155
+ const current2 = profiles();
1156
+ const facts = registrationFacts(current2);
1157
+ if (deepEqualJson(facts, registeredFacts)) return;
1158
+ const routes = [...current2.keys()];
1159
+ if (registrations === void 0) {
1160
+ if (routes.length === 0) {
1161
+ registeredFacts = facts;
1162
+ return;
1163
+ }
1164
+ registrations = registerGroup(routes, (error) => {
1165
+ logger.warn("llm-pi: 整批注册失败(可能 route 名冲突),降级为逐个 route 注册");
1166
+ logger.warn(error);
1167
+ });
1168
+ } else try {
1169
+ registrations[0].handle.replace(routes);
1170
+ for (let i = 1; i < registrations.length; i += 1) registrations[i].handle.replace([]);
1171
+ registrations = [{
1172
+ routes,
1173
+ handle: registrations[0].handle
1174
+ }];
1175
+ } catch (error) {
1176
+ logger.error("llm-pi: 更新被拒,保留此前注册的 route");
1177
+ logger.error(error);
1178
+ }
1179
+ registeredFacts = facts;
1180
+ };
1181
+ let directory;
1182
+ let directoryFacts;
1183
+ const ensureDirectory = () => {
1184
+ const entries = [...profiles().entries()].map(([provider, profile]) => ({
1185
+ provider,
1186
+ displayName: profile.displayName,
1187
+ settingsNs: SETTINGS_NS,
1188
+ settingsPath: ["providers", provider],
1189
+ declared: !hasBuiltinProvider(kit, provider)
1190
+ }));
1191
+ if (deepEqualJson(entries, directoryFacts)) return;
1192
+ if (entries.length === 0) {
1193
+ directoryFacts = entries;
1194
+ return;
1195
+ }
1196
+ if (directory === void 0) directory = ctx.llm.registerConfigurableProviders(entries);
1197
+ else directory.replace(entries);
1198
+ directoryFacts = entries;
1199
+ };
1200
+ ensureRegistration();
1201
+ ensureDirectory();
1202
+ installSettingsSection(ctx, SETTINGS_NS, Config, config, {
1203
+ validate: (cfg) => assertServiceable(cfg, deps),
1204
+ setSource: (source) => {
1205
+ current = source;
1206
+ },
1207
+ onChange: () => {
1208
+ try {
1209
+ ensureRegistration();
1210
+ } catch (error) {
1211
+ logger.error("llm-pi: 更新被拒,保留此前注册的 route");
1212
+ logger.error(error);
1213
+ }
1214
+ try {
1215
+ ensureDirectory();
1216
+ } catch (error) {
1217
+ logger.error("llm-pi: 更新被拒,保留此前的 configurable-provider 目录");
1218
+ logger.error(error);
1219
+ }
1220
+ const cfg = current();
1221
+ modelsDev.reconfigure(cfg.catalogUrl, cfg.catalogRefreshHours, cfg.catalogProxy ?? "");
1222
+ }
1223
+ });
1224
+ return {
1225
+ currentConfig: () => current(),
1226
+ kitInfo: () => ({
1227
+ source: kit.source,
1228
+ diagnostics
1229
+ }),
1230
+ modelsDev,
1231
+ kit
1232
+ };
1233
+ }
1234
+
1235
+ //#endregion
1236
+ //#region src/index.ts
1237
+ const name = "dsh-plus-llm-pi";
1238
+ const inject = ["llm"];
1239
+ async function apply(ctx, config) {
1240
+ const runtime = await startRuntime(ctx, config);
1241
+ ctx.inject(["webServer"], (webCtx) => {
1242
+ registerConfigApi(webCtx, runtime);
1243
+ });
1244
+ }
1245
+
1246
+ //#endregion
1247
+ export { Config, SETTINGS_NS, apply, inject, name };