@dsh-plus/llm-pi 0.1.45 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1147,6 +1147,13 @@ ${extra}
1147
1147
  function sourceLabel(t, source) {
1148
1148
  return source === "official" ? t("runtimeDerived") : t("runtimeFallback");
1149
1149
  }
1150
+ /** 应急副本状态行:路径 · route 数 · 生成时间;未生成/失败显式提示。 */
1151
+ function officialCopyText(kit, t) {
1152
+ const copy = kit.officialCopy;
1153
+ if (copy.error !== void 0) return `${t("officialCopyFailed")}${copy.error}`;
1154
+ if (copy.updatedAt === void 0) return t("officialCopyPending");
1155
+ return `${copy.path} · ${copy.routes} ${t("officialCopyRoutes")} · ${formatTime(copy.updatedAt, t("runtimeVersionUnknown"))}`;
1156
+ }
1150
1157
  function RootFields(props) {
1151
1158
  const { t, draft, disabled, kit, onEdit } = props;
1152
1159
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
@@ -1190,6 +1197,10 @@ ${extra}
1190
1197
  className: "lpc-statusRow",
1191
1198
  children: `${t("runtimeCompat")}:${sourceLabel(t, kit.compatSource)}`
1192
1199
  }),
1200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1201
+ className: "lpc-statusRow",
1202
+ children: `${t("runtimeOfficialCopy")}:${officialCopyText(kit, t)}`
1203
+ }),
1193
1204
  kit.versionNotice !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1194
1205
  className: "lpc-invalid",
1195
1206
  children: `${t("runtimeNotice")}:${kit.versionNotice}`
@@ -2851,6 +2862,10 @@ ${extra}
2851
2862
  runtimeCatalog: "内置模型目录",
2852
2863
  runtimeProtocols: "可服务协议",
2853
2864
  runtimeCompat: "compat 门控表",
2865
+ runtimeOfficialCopy: "应急副本",
2866
+ officialCopyPending: "尚未生成",
2867
+ officialCopyFailed: "生成失败:",
2868
+ officialCopyRoutes: "个 route",
2854
2869
  runtimeDiagnostics: "诊断",
2855
2870
  runtimeVersionUnknown: "未知",
2856
2871
  runtimeCatalogUnit: "个 provider /",
@@ -2981,6 +2996,10 @@ ${extra}
2981
2996
  runtimeCatalog: "Built-in catalog",
2982
2997
  runtimeProtocols: "Servable protocols",
2983
2998
  runtimeCompat: "compat gates",
2999
+ runtimeOfficialCopy: "Emergency copy",
3000
+ officialCopyPending: "not generated yet",
3001
+ officialCopyFailed: "generation failed: ",
3002
+ officialCopyRoutes: "routes",
2984
3003
  runtimeDiagnostics: "Diagnostics",
2985
3004
  runtimeVersionUnknown: "unknown",
2986
3005
  runtimeCatalogUnit: "providers /",
package/lib/index.d.ts CHANGED
@@ -95,7 +95,7 @@ declare const Config: z<LlmPiConfigInput, LlmPiConfigFields>;
95
95
  //#endregion
96
96
  //#region src/index.d.ts
97
97
  declare const name = "dsh-plus-llm-pi";
98
- declare const inject: readonly ['credentials', 'llm'];
98
+ declare const inject: readonly ['credentials', 'llm', 'settings'];
99
99
  declare function apply(ctx: Context, config: LlmPiConfig): Promise<void>;
100
100
  //#endregion
101
101
  export { Config, type LlmPiConfig, type ModelEntryConfig, type ProviderProfileConfig, SETTINGS_NS, apply, inject, name };
package/lib/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
3
3
  import { deepEqualJson } from "@deepseek-ai/dsh-util-values";
4
- import { hasVolatileRefs, unwrapVolatile } from "@dsh-plus/shared";
4
+ import { atomicWriteFile, hasVolatileRefs, isVolatileRef, unwrapVolatile } from "@dsh-plus/shared";
5
5
  import { existsSync, readFileSync, realpathSync } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { access, stat } from "node:fs/promises";
9
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
10
+ import { stringify } from "yaml";
8
11
  import { credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
9
12
  import * as vendoredAnonId from "@deepseek-ai/dsh-anonymous-user-id";
10
13
  import * as vendoredLlm from "@deepseek-ai/dsh-llm";
@@ -16,7 +19,6 @@ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messag
16
19
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
17
20
  import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
18
21
  import * as vendoredCatalog from "@earendil-works/pi-ai/providers/all";
19
- import { access } from "node:fs/promises";
20
22
  import { homedir } from "node:os";
21
23
  //#endregion
22
24
  //#region src/config.ts
@@ -771,6 +773,10 @@ function compatTableInfo() {
771
773
  problem: active.problem
772
774
  };
773
775
  }
776
+ /** 某协议某字段的可配性('offer'/'withhold';未列出 = 无此字段)。 */
777
+ function compatDispositionOf(api, field) {
778
+ return active.gates[api]?.[field];
779
+ }
774
780
  /** 某协议全部可配置(offer)的 compat 键(UI 渲染字段组与校验共用)。 */
775
781
  function compatFieldsOf(api) {
776
782
  const gate = active.gates[api];
@@ -2044,11 +2050,390 @@ function buildProfiles(providers, deps) {
2044
2050
  }
2045
2051
  return resolved;
2046
2052
  }
2053
+ /**
2054
+ * 应急副本用:逐 route 归一化为官方 schema 形状的 profile(与运行期同一条
2055
+ * normalizeRoute 链:extends 物化、单协议单端点约束、模型条目全显式)。
2056
+ *
2057
+ * 与运行期 buildProfiles 的两点差异:
2058
+ * - 不受 `enabled` 开关影响——副本是配置层事实,插件关闭时其 route 仍应可复制;
2059
+ * - 逐 route 隔离失败(不依赖 lenient 开关)——一个坏条目只丢它自己,
2060
+ * 不让整份应急副本变空。
2061
+ *
2062
+ * adapter: deepseek 的 route 与草稿路由不在此列(前者由 profiles-deepseek
2063
+ * 物化、后者本就不可服务),调用方按缺席另行告警。
2064
+ */
2065
+ function normalizeOfficialRoutes(providers, deps) {
2066
+ const out = /* @__PURE__ */ new Map();
2067
+ for (const [route, profile] of Object.entries(providers ?? {})) try {
2068
+ const normalized = normalizeRoute(route, profile, deps);
2069
+ if (normalized !== void 0) out.set(route, normalized.profile);
2070
+ } catch (error) {
2071
+ deps.warn?.(`llm-pi: provider "${route}" 无法物化为官方形状(${error instanceof Error ? error.message : String(error)}),已跳过应急副本的该 route`);
2072
+ }
2073
+ return out;
2074
+ }
2047
2075
  /** settings 写入校验钩子:完整试跑解析,非法配置在写入处拒绝。 */
2048
2076
  function assertServiceable(config, deps) {
2049
2077
  buildProfiles(config.providers, deps);
2050
2078
  buildDeepseekRoutes(config.providers, deps);
2051
2079
  }
2080
+ //#endregion
2081
+ //#region src/official-copy.ts
2082
+ /**
2083
+ * 官方应急副本的翻译与渲染(纯函数,零 IO)。
2084
+ *
2085
+ * 产出一份「官方可识别」的 profile patch 覆盖层:把 dsh-plus-llm-pi 的 route
2086
+ * 配置改写为官方 `llm-pi-ai` 的 Config 形状,另附一行禁用 dsh-plus-llm-pi 的
2087
+ * 覆盖(应用即切换,同名 route 不撞 DUPLICATE_ADAPTER)。写盘、逐 route 官方
2088
+ * 校验与状态管理见 official-copy-writer.ts。
2089
+ *
2090
+ * 三条翻译底线(对旧「出错自动改写」失效教训的根治):
2091
+ * 1. **只产出官方 schema 声明的字段**:route 键集从官方 Config schema 现场推导
2092
+ * (失败回退内置键集),模型条目按 kit.officialModelFields 过滤——官方拒收
2093
+ * 的字段根本不出现在副本里;
2094
+ * 2. **compat 只留 offer**:withhold 与未知键按生效门控表丢弃(目录继承值可能
2095
+ * 携带 withhold 键,写时会被官方拒绝);
2096
+ * 3. **继承全物化**:官方无 extends——模型条目来自 normalizeRoute 的全显式物化
2097
+ * 产物(单协议、单端点约束由该链保证,无需拆分 route)。
2098
+ *
2099
+ * deepseek 路由改写为 openai-completions(中继网关本就是 OpenAI 兼容面):
2100
+ * 文件通道与思考配置不迁移(官方 llm-pi-ai 无对应字段/无逐模型 reasoningEfforts),
2101
+ * 以 warning 明示,绝不静默丢弃。
2102
+ * @module llm-pi/official-copy
2103
+ */
2104
+ /** 副本文件在 Harness home(.dsh)根目录的落点;lifeboat 面板按同一路径消费(文件契约)。 */
2105
+ const OFFICIAL_COPY_PATH = dshHomePath("llm-pi.official-patch.yaml");
2106
+ /** deepseek 路由改写所用线协议:中继网关对外提供 OpenAI 兼容 chat completions 面。 */
2107
+ const DEEPSEEK_COPY_API = "openai-completions";
2108
+ /**
2109
+ * 官方 Config schema 布局不可识别时的 route 字段兜底(官方 PiAiProviderProfile
2110
+ * 最后已知键集)。与官方新增字段的失配只表现为「新字段不进副本」,不弄挂生成。
2111
+ */
2112
+ const FALLBACK_ROUTE_FIELDS = [
2113
+ "apiKeyEnv",
2114
+ "displayName",
2115
+ "api",
2116
+ "baseURL",
2117
+ "models",
2118
+ "modelOverrides",
2119
+ "compat",
2120
+ "defaultContextWindow",
2121
+ "defaultMaxTokens",
2122
+ "defaultInput",
2123
+ "headers",
2124
+ "reasoning",
2125
+ "thinkingBudgets",
2126
+ "cacheRetention",
2127
+ "transport",
2128
+ "timeoutMs",
2129
+ "websocketConnectTimeoutMs",
2130
+ "streamIdleTimeoutMs",
2131
+ "maxRequestImageBytes",
2132
+ "requestImagePixelBudget",
2133
+ "requestImageMaxBytes",
2134
+ "retryPolicy"
2135
+ ];
2136
+ /** deepseek 专有且官方 llm-pi-ai 无对应字段的 raw 键(出现即记 warning)。 */
2137
+ const DEEPSEEK_UNMIGRATED_FIELDS = [
2138
+ "thinking",
2139
+ "reasoningEffort",
2140
+ "maxRequestFilesBytes",
2141
+ "filesApiTimeoutMs",
2142
+ "fileExpiresAfterSeconds",
2143
+ "fileRefreshMarginSeconds",
2144
+ "maxImagesPerRequest",
2145
+ "imageOffloadByteQuantum",
2146
+ "inlineImageOffloadByteQuantum",
2147
+ "imageOffloadCountQuantum"
2148
+ ];
2149
+ /**
2150
+ * 从官方 Config schema 推导 route 字段键集(`providers.*` 的 dict 键);
2151
+ * 布局不可识别/键集为空返回 undefined,调用方回退内置键集并告警。
2152
+ */
2153
+ function deriveOfficialRouteFields(config) {
2154
+ const fields = config.dict?.["providers"]?.inner?.dict;
2155
+ if (fields === void 0) return void 0;
2156
+ const keys = Object.keys(fields);
2157
+ return keys.length > 0 ? keys : void 0;
2158
+ }
2159
+ /** compat 过滤:只保留该协议 offer 的字段(withhold/未知键丢弃);无保留项返回 undefined。 */
2160
+ function offerCompat(api, compat) {
2161
+ if (compat === void 0 || typeof compat !== "object") return void 0;
2162
+ const out = {};
2163
+ for (const [field, value] of Object.entries(compat)) {
2164
+ if (value === void 0 || value === null) continue;
2165
+ if (compatDispositionOf(api, field) === "offer") out[field] = value;
2166
+ }
2167
+ return Object.keys(out).length > 0 ? out : void 0;
2168
+ }
2169
+ /** 单个模型条目 → 官方键集内的条目(compat 另过 offer 门控)。 */
2170
+ function filterModel(api, model, modelFields) {
2171
+ const source = model;
2172
+ const out = {};
2173
+ for (const key of modelFields) {
2174
+ const value = source[key];
2175
+ if (value === void 0) continue;
2176
+ if (key === "compat") {
2177
+ const compat = offerCompat(api, value);
2178
+ if (compat !== void 0) out.compat = compat;
2179
+ continue;
2180
+ }
2181
+ out[key] = value;
2182
+ }
2183
+ return out;
2184
+ }
2185
+ /** 归一化 profile → 官方 route 字段内的条目(models 逐条过滤)。 */
2186
+ function filterRoute(profile, routeFields, modelFields) {
2187
+ const source = profile;
2188
+ const api = typeof source["api"] === "string" ? source["api"] : "";
2189
+ const out = {};
2190
+ for (const key of routeFields) {
2191
+ const value = source[key];
2192
+ if (value === void 0) continue;
2193
+ if (key === "models") {
2194
+ out.models = (Array.isArray(value) ? value : []).map((model) => filterModel(api, model, modelFields));
2195
+ continue;
2196
+ }
2197
+ out[key] = value;
2198
+ }
2199
+ return out;
2200
+ }
2201
+ /** deepseek 单模型 → 官方条目(inputModalities 改名 input;图像预算不迁移)。 */
2202
+ function deepseekModelEntry(model) {
2203
+ const out = { id: model.id };
2204
+ if (model.name !== void 0) out.name = model.name;
2205
+ if (model.contextWindow !== void 0) out.contextWindow = model.contextWindow;
2206
+ if (model.maxTokens !== void 0) out.maxTokens = model.maxTokens;
2207
+ if (model.inputModalities !== void 0) out.input = [...model.inputModalities];
2208
+ return out;
2209
+ }
2210
+ /**
2211
+ * deepseek 路由 → 官方 llm-pi-ai profile:连接事实取 resolveAdapterOptions 的
2212
+ * 校验产物(端点/凭据引用/限额),模型取官方目录物化结果。思考配置与文件通道
2213
+ * 字段不迁移(llm-pi-ai 无对应语义),逐项列入 unmirrored。
2214
+ */
2215
+ function deepseekCopyProfile(raw, resolved) {
2216
+ const connection = resolved.connection;
2217
+ const unmirrored = DEEPSEEK_UNMIGRATED_FIELDS.filter((key) => raw[key] !== void 0);
2218
+ if (raw.models?.some((m) => m.imagePixelBudget !== void 0 || m.imageMaxBytes !== void 0)) unmirrored.push("模型级 imagePixelBudget/imageMaxBytes");
2219
+ const profile = {
2220
+ displayName: resolved.displayName,
2221
+ api: DEEPSEEK_COPY_API,
2222
+ baseURL: connection.baseURL,
2223
+ apiKeyEnv: raw.apiKeyEnv,
2224
+ defaultContextWindow: connection.defaultContextWindow,
2225
+ defaultMaxTokens: connection.maxTokens,
2226
+ streamIdleTimeoutMs: connection.streamIdleTimeoutMs,
2227
+ models: connection.models.map(deepseekModelEntry)
2228
+ };
2229
+ if (raw.retryPolicy !== void 0) profile.retryPolicy = raw.retryPolicy;
2230
+ if (raw.maxInlineRequestImageBytes !== void 0) profile.maxRequestImageBytes = connection.maxInlineRequestImageBytes;
2231
+ return {
2232
+ profile,
2233
+ unmirrored
2234
+ };
2235
+ }
2236
+ /** 翻译整份配置为官方副本内容(纯函数;坏 route 的跳过告警由上游 warn 通道汇集)。 */
2237
+ function buildOfficialCopy(input) {
2238
+ const warnings = [];
2239
+ const providers = {};
2240
+ for (const [route, raw] of Object.entries(input.providers)) {
2241
+ if ((raw.adapter ?? "pi") === "deepseek") {
2242
+ const resolved = input.deepseekResolved.get(route);
2243
+ if (resolved === void 0) continue;
2244
+ const { profile, unmirrored } = deepseekCopyProfile(raw, resolved);
2245
+ providers[route] = profile;
2246
+ if (unmirrored.length > 0) warnings.push(`deepseek route "${route}" 的字段未迁移到 llm-pi-ai:${unmirrored.join("、")}`);
2247
+ continue;
2248
+ }
2249
+ const normalized = input.normalizedPi.get(route);
2250
+ if (normalized === void 0) {
2251
+ if (isDraftRoute(raw)) warnings.push(`草稿 route "${route}" 未纳入副本(尚无模型)`);
2252
+ continue;
2253
+ }
2254
+ providers[route] = filterRoute(normalized, input.routeFields, input.modelFields);
2255
+ }
2256
+ for (const [route, profile] of Object.entries(input.manualProviders ?? {})) if (profile !== null && typeof profile === "object") providers[route] = profile;
2257
+ return {
2258
+ providers,
2259
+ warnings
2260
+ };
2261
+ }
2262
+ /** 头注释:用途、两种应用方式、切换语义、回退方法与警告清单(确定性输出,mtime 即生成时间)。 */
2263
+ function renderHeader(copy, meta) {
2264
+ const profile = meta.profile ?? "<profile>";
2265
+ return [
2266
+ "# 官方应急 LLM 配置副本 —— @dsh-plus/llm-pi 自动生成(勿手改:下次配置变更会被覆盖;文件 mtime 即最近生成时间)",
2267
+ `# 来源 profile:${profile}`,
2268
+ "# 用途:dsh-plus-llm-pi 缺席/故障时,用本文件把其 route 以官方 llm-pi-ai 配置恢复 LLM 能力。",
2269
+ "# 应用(二选一,重启/重载后生效):",
2270
+ `# 1) 单次启动:dsh ${profile} --patch ${OFFICIAL_COPY_PATH}`,
2271
+ `# 2) 长期生效:把本文件条目并入该 profile 的 cordis.patch.yml(~/.dsh/profiles/${profile}/)`,
2272
+ "# 语义:应用即切换 —— 副本禁用 dsh-plus-llm-pi,并整行替换 llm-pi-ai 的 config",
2273
+ "# (官方 llm-pi-ai 现有 providers 已合并保留;同名 key 以手写条目优先)。",
2274
+ "# 回退:移除 --patch 参数或删除并入的条目(含 dsh-plus-llm-pi 的 disabled 行),重启/重载。",
2275
+ `# 本次内容:${Object.keys(copy.providers).length} 个官方 provider route。`,
2276
+ ...copy.warnings.map((warning) => `# 警告:${warning}`)
2277
+ ].join("\n");
2278
+ }
2279
+ /**
2280
+ * 渲染副本文件内容(确定性:同输入同字节,mtime 承载时间语义)。
2281
+ * 零 route 时渲染空补丁列表 `[]`(应用无效果)——绝不产出「只禁用不供给」的误导性副本。
2282
+ */
2283
+ function renderOfficialCopy(copy, meta) {
2284
+ const header = renderHeader(copy, meta);
2285
+ if (Object.keys(copy.providers).length === 0) return `${header}\n# 当前没有可迁移的 route(llm-pi 未配置或全部不可服务),空补丁应用无效果。\n[]\n`;
2286
+ const rows = [{
2287
+ id: "dsh-plus-llm-pi",
2288
+ disabled: true
2289
+ }, {
2290
+ id: "llm-pi-ai",
2291
+ config: { providers: copy.providers }
2292
+ }];
2293
+ return `${header}\n${stringify(rows, { lineWidth: 0 })}`;
2294
+ }
2295
+ //#endregion
2296
+ //#region src/official-copy-writer.ts
2297
+ /**
2298
+ * 官方应急副本的生成编排:装配(原始配置 → 物化 → 翻译)→ 逐 route 官方校验
2299
+ * → 备忘跳过 / 原子落盘 → 状态供配置卡与 lifeboat 面板消费。
2300
+ *
2301
+ * 触发时机:插件启动、配置热更新(loader/volatile-update)、adapter 注册变化
2302
+ * (llm/adapters-updated,官方 llm-pi-ai 晚注册时手写条目在此补入)。无定时器、
2303
+ * 无 watcher——平时零开销;写失败只降级为状态与日志,绝不影响路由注册。
2304
+ *
2305
+ * 逐 route 官方校验是「副本必然官方可识别」的执行点:官方 Config schema 解析
2306
+ * (非法协议/越界取值在此抛错)+ 官方 resolveProfiles 可服务性链;任一不过即
2307
+ * 剔除该 route 并记 warning——写不进官方的东西绝不落盘。
2308
+ * @module llm-pi/official-copy-writer
2309
+ */
2310
+ /** 官方 llm-pi-ai 的 settings 命名空间(与插件 NS 同字面量,官方 0.1.x 起稳定)。 */
2311
+ const NS_OFFICIAL_PI_AI = "llm-pi-ai";
2312
+ /**
2313
+ * 手写官方条目来源:settings describe 的 llm-pi-ai 生效视图(composition base
2314
+ * = profile patch 行 + settings 用户层,schema 已解析、已过官方 validate)——
2315
+ * 官方 seam,无需自行解析 patch 文件。ns 未注册/读取失败时降级为「仅本插件
2316
+ * 改写条目」并附 warning,不阻断生成。
2317
+ */
2318
+ function readManualProviders(settings) {
2319
+ try {
2320
+ const row = settings.describe().find((entry) => entry.ns === NS_OFFICIAL_PI_AI);
2321
+ if (row === void 0) return { warning: "官方 llm-pi-ai 未注册(插件缺席),副本仅含本插件改写条目" };
2322
+ const raw = row.value?.providers;
2323
+ const providers = isVolatileRef(raw) ? raw.get() : raw;
2324
+ if (providers === null || typeof providers !== "object") return {};
2325
+ return { providers };
2326
+ } catch (error) {
2327
+ return { warning: `读取官方 llm-pi-ai 生效配置失败(${error instanceof Error ? error.message : String(error)}),副本仅含本插件改写条目` };
2328
+ }
2329
+ }
2330
+ /** 文件是否已存在(内容备忘跳过后自愈外部删除)。 */
2331
+ async function fileExists(path) {
2332
+ try {
2333
+ return (await stat(path)).isFile();
2334
+ } catch {
2335
+ return false;
2336
+ }
2337
+ }
2338
+ /** 组装 + 翻译:返回含全部 warning 的副本内容(尚未官方校验)。 */
2339
+ function assemble(deps, warnings) {
2340
+ const routeFields = deriveOfficialRouteFields(deps.kit.officialConfig);
2341
+ if (routeFields === void 0) warnings.push("官方 Config schema 布局不可识别,route 字段回退内置键集");
2342
+ const providers = deps.current().providers ?? {};
2343
+ const normalizedPi = normalizeOfficialRoutes(providers, {
2344
+ kit: deps.kit,
2345
+ lenient: true,
2346
+ warn: (message) => warnings.push(message)
2347
+ });
2348
+ const deepseekResolved = buildDeepseekRoutes(providers, {
2349
+ kit: deps.kit,
2350
+ lenient: true,
2351
+ warn: (message) => warnings.push(message)
2352
+ });
2353
+ const manual = deps.readManual();
2354
+ if (manual.warning !== void 0) warnings.push(manual.warning);
2355
+ const copy = buildOfficialCopy({
2356
+ providers,
2357
+ normalizedPi,
2358
+ deepseekResolved,
2359
+ manualProviders: manual.providers,
2360
+ routeFields: routeFields ?? [...FALLBACK_ROUTE_FIELDS],
2361
+ modelFields: deps.kit.officialModelFields
2362
+ });
2363
+ warnings.push(...copy.warnings);
2364
+ return {
2365
+ providers: copy.providers,
2366
+ warnings
2367
+ };
2368
+ }
2369
+ /** 逐 route 官方校验:schema 解析 + 可服务性链;不过即剔除并记 warning。 */
2370
+ function acceptOfficial(deps, copy, warnings) {
2371
+ const accepted = {};
2372
+ for (const [route, profile] of Object.entries(copy.providers)) {
2373
+ try {
2374
+ deps.kit.officialConfig({ providers: { [route]: profile } });
2375
+ deps.kit.resolveProfiles({ [route]: profile });
2376
+ } catch (error) {
2377
+ const message = error instanceof Error ? error.message : String(error);
2378
+ warnings.push(`route "${route}" 未通过官方校验,已从副本剔除:${message}`);
2379
+ continue;
2380
+ }
2381
+ accepted[route] = profile;
2382
+ }
2383
+ return accepted;
2384
+ }
2385
+ /** 落盘:内容未变且文件仍在则跳过(并自愈外部删除);返回最近生成时间。 */
2386
+ async function persist(path, content, state) {
2387
+ if (content === state.lastContent && await fileExists(path)) return state.updatedAt ?? Date.now();
2388
+ await atomicWriteFile(path, content);
2389
+ state.lastContent = content;
2390
+ state.updatedAt = Date.now();
2391
+ return state.updatedAt;
2392
+ }
2393
+ /** 生成一次副本(错误上抛,由调用方降级为 status.error)。 */
2394
+ async function runOnce(deps, path, state) {
2395
+ const warnings = [];
2396
+ const providers = acceptOfficial(deps, assemble(deps, warnings), warnings);
2397
+ return {
2398
+ path,
2399
+ updatedAt: await persist(path, renderOfficialCopy({
2400
+ providers,
2401
+ warnings
2402
+ }, { profile: deps.profile }), state),
2403
+ routes: Object.keys(providers).length,
2404
+ warnings
2405
+ };
2406
+ }
2407
+ /** 装配写入器:启动/热更新/adapter 事件驱动,串行执行(promise 链)。 */
2408
+ function createOfficialCopyWriter(deps) {
2409
+ const path = deps.path ?? OFFICIAL_COPY_PATH;
2410
+ const state = {};
2411
+ let status = {
2412
+ path,
2413
+ routes: 0,
2414
+ warnings: []
2415
+ };
2416
+ let chain = Promise.resolve();
2417
+ const ensureOfficialCopy = () => {
2418
+ chain = chain.then(async () => {
2419
+ try {
2420
+ status = await runOnce(deps, path, state);
2421
+ } catch (error) {
2422
+ const message = error instanceof Error ? error.message : String(error);
2423
+ deps.logger.warn(`llm-pi: 应急副本生成失败:${message}`);
2424
+ status = {
2425
+ ...status,
2426
+ error: message
2427
+ };
2428
+ }
2429
+ });
2430
+ return chain;
2431
+ };
2432
+ return {
2433
+ ensureOfficialCopy,
2434
+ status: () => status
2435
+ };
2436
+ }
2052
2437
  /** 官方 jsonImage:pi-ai 凭据里显式 undefined 成员 JSON 序列化为缺省/数组 null。 */
2053
2438
  function jsonImage(value) {
2054
2439
  if (Array.isArray(value)) return value.map((entry) => entry === void 0 ? null : jsonImage(entry));
@@ -2196,6 +2581,7 @@ function assertKitShape(kit, origin) {
2196
2581
  "providerRetryPolicy"
2197
2582
  ]) if (typeof kit.PiAiAdapter.prototype[method] !== "function") problems.push(`PiAiAdapter.prototype.${method} 缺失`);
2198
2583
  if (typeof kit.createProvider !== "function") problems.push("pi-ai createProvider 缺失");
2584
+ if (typeof kit.officialConfig !== "function") problems.push("dsh-llm-pi-ai Config schema 缺失");
2199
2585
  if (typeof kit.getBuiltinModels !== "function") problems.push("pi-ai getBuiltinModels 缺失");
2200
2586
  if (typeof kit.builtinProviders !== "function") problems.push("pi-ai builtinProviders 缺失");
2201
2587
  if (kit.protocols.length === 0) problems.push("没有可服务的线协议(协议工厂全部加载失败)");
@@ -2431,6 +2817,7 @@ function kitFromTree(options) {
2431
2817
  officialModelFields: surface.officialModelFields,
2432
2818
  ...surface.catalogGeneratedAt === void 0 ? {} : { catalogGeneratedAt: surface.catalogGeneratedAt },
2433
2819
  PiAiAdapter: mods.piAiAdapter["PiAiAdapter"],
2820
+ officialConfig: mods.piAiAdapter["Config"],
2434
2821
  LlmError: mods.llm["LlmError"],
2435
2822
  resolveRetryPolicy: mods.llm["resolveRetryPolicy"],
2436
2823
  attributionHeaders: mods.llm["attributionHeaders"],
@@ -2509,6 +2896,7 @@ function loadVendoredKit(diagnostics = []) {
2509
2896
  officialModelFields: surface.officialModelFields,
2510
2897
  ...surface.catalogGeneratedAt === void 0 ? {} : { catalogGeneratedAt: surface.catalogGeneratedAt },
2511
2898
  PiAiAdapter: vendoredPiAiAdapter.PiAiAdapter,
2899
+ officialConfig: vendoredPiAiAdapter.Config,
2512
2900
  LlmError: vendoredLlm.LlmError,
2513
2901
  resolveRetryPolicy: vendoredLlm.resolveRetryPolicy,
2514
2902
  attributionHeaders: vendoredLlm.attributionHeaders,
@@ -2848,16 +3236,26 @@ async function startRuntime(ctx, rawConfig) {
2848
3236
  routes: deepseekRoutes
2849
3237
  });
2850
3238
  const ensureDeepseek = () => deepseekRegistrar.sync(deepseekRoutes());
3239
+ const copyWriter = createOfficialCopyWriter({
3240
+ kit,
3241
+ current,
3242
+ readManual: () => readManualProviders(ctx.settings),
3243
+ profile: ctx.get("profileContext")?.name,
3244
+ logger
3245
+ });
2851
3246
  ensureRegistration();
2852
3247
  ensureDeepseek();
2853
3248
  ensureDirectory();
3249
+ copyWriter.ensureOfficialCopy();
2854
3250
  const reconfigure = () => {
2855
3251
  snapshot = unwrapVolatile(fields);
2856
3252
  attempt(logger, "llm-pi: 更新被拒,保留此前注册的 route", ensureRegistration);
2857
3253
  attempt(logger, "llm-pi: deepseek route 更新失败,保留此前注册", ensureDeepseek);
2858
3254
  attempt(logger, "llm-pi: 更新被拒,保留此前的 configurable-provider 目录", ensureDirectory);
3255
+ copyWriter.ensureOfficialCopy();
2859
3256
  };
2860
3257
  ctx.events.on("loader/volatile-update", reconfigure);
3258
+ ctx.root.on("llm/adapters-updated", () => void copyWriter.ensureOfficialCopy());
2861
3259
  ctx.on("internal/config", function(_raw, next) {
2862
3260
  const candidate = next();
2863
3261
  if (this !== ctx.fiber) return candidate;
@@ -2869,6 +3267,7 @@ async function startRuntime(ctx, rawConfig) {
2869
3267
  const providers = listProviders(kit);
2870
3268
  const generatedAt = kit.catalogGeneratedAt;
2871
3269
  const notice = piAiVersionNotice(kit.versions.piAi);
3270
+ const copy = copyWriter.status();
2872
3271
  return {
2873
3272
  source: kit.source,
2874
3273
  ...kit.root === void 0 ? {} : { root: kit.root },
@@ -2883,7 +3282,12 @@ async function startRuntime(ctx, rawConfig) {
2883
3282
  ...generatedAt === void 0 ? {} : { generatedAt }
2884
3283
  },
2885
3284
  compatSource: compatTableInfo().source,
2886
- diagnostics
3285
+ officialCopy: copy,
3286
+ diagnostics: [
3287
+ ...diagnostics,
3288
+ ...copy.warnings.map((warning) => `应急副本:${warning}`),
3289
+ ...copy.error === void 0 ? [] : [`应急副本:生成失败(${copy.error})`]
3290
+ ]
2887
3291
  };
2888
3292
  };
2889
3293
  return {
@@ -2897,7 +3301,11 @@ async function startRuntime(ctx, rawConfig) {
2897
3301
  //#endregion
2898
3302
  //#region src/index.ts
2899
3303
  const name = "dsh-plus-llm-pi";
2900
- const inject = ["credentials", "llm"];
3304
+ const inject = [
3305
+ "credentials",
3306
+ "llm",
3307
+ "settings"
3308
+ ];
2901
3309
  async function apply(ctx, config) {
2902
3310
  const runtime = await startRuntime(ctx, config);
2903
3311
  ctx.inject(["webServer"], (webCtx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/llm-pi",
3
- "version": "0.1.45",
3
+ "version": "0.2.0",
4
4
  "description": "dsh-plus service+ui plugin: 基于 PiAiAdapter 的自定义 LLM 路由(全量 compat、模型继承、pi-ai 内置目录;协议/compat/字段集现场推导,自动跟随已装 dsh)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -31,6 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
+ "yaml": "^2.9.0",
34
35
  "@dsh-plus/shared": "0.1.35"
35
36
  },
36
37
  "peerDependencies": {
package/src/client/api.ts CHANGED
@@ -18,6 +18,14 @@ export interface WireKitInfo {
18
18
  protocolSource: string
19
19
  catalog: { providers: number; models: number; generatedAt?: number }
20
20
  compatSource: string
21
+ /** 官方应急副本状态(服务端 official-copy-writer 的 OfficialCopyStatus 镜像)。 */
22
+ officialCopy: {
23
+ path: string
24
+ updatedAt?: number
25
+ routes: number
26
+ warnings: string[]
27
+ error?: string
28
+ }
21
29
  diagnostics: string[]
22
30
  }
23
31
 
@@ -23,6 +23,10 @@ const ownZh = {
23
23
  runtimeCatalog: '内置模型目录',
24
24
  runtimeProtocols: '可服务协议',
25
25
  runtimeCompat: 'compat 门控表',
26
+ runtimeOfficialCopy: '应急副本',
27
+ officialCopyPending: '尚未生成',
28
+ officialCopyFailed: '生成失败:',
29
+ officialCopyRoutes: '个 route',
26
30
  runtimeDiagnostics: '诊断',
27
31
  runtimeVersionUnknown: '未知',
28
32
  runtimeCatalogUnit: '个 provider /',
@@ -160,6 +164,10 @@ const ownEn = {
160
164
  runtimeCatalog: 'Built-in catalog',
161
165
  runtimeProtocols: 'Servable protocols',
162
166
  runtimeCompat: 'compat gates',
167
+ runtimeOfficialCopy: 'Emergency copy',
168
+ officialCopyPending: 'not generated yet',
169
+ officialCopyFailed: 'generation failed: ',
170
+ officialCopyRoutes: 'routes',
163
171
  runtimeDiagnostics: 'Diagnostics',
164
172
  runtimeVersionUnknown: 'unknown',
165
173
  runtimeCatalogUnit: 'providers /',
@@ -36,6 +36,14 @@ function sourceLabel(t: Translate, source: string): string {
36
36
  return source === 'official' ? t('runtimeDerived') : t('runtimeFallback')
37
37
  }
38
38
 
39
+ /** 应急副本状态行:路径 · route 数 · 生成时间;未生成/失败显式提示。 */
40
+ function officialCopyText(kit: WireKitInfo, t: Translate): string {
41
+ const copy = kit.officialCopy
42
+ if (copy.error !== undefined) return `${t('officialCopyFailed')}${copy.error}`
43
+ if (copy.updatedAt === undefined) return t('officialCopyPending')
44
+ return `${copy.path} · ${copy.routes} ${t('officialCopyRoutes')} · ${formatTime(copy.updatedAt, t('runtimeVersionUnknown'))}`
45
+ }
46
+
39
47
  export interface RootFieldsProps {
40
48
  t: Translate
41
49
  draft: Draft
@@ -80,6 +88,9 @@ export function RootFields(props: RootFieldsProps): ReactElement {
80
88
  <p className="lpc-statusRow">
81
89
  {`${t('runtimeCompat')}:${sourceLabel(t, kit.compatSource)}`}
82
90
  </p>
91
+ <p className="lpc-statusRow">
92
+ {`${t('runtimeOfficialCopy')}:${officialCopyText(kit, t)}`}
93
+ </p>
83
94
  {kit.versionNotice !== undefined ? (
84
95
  <p className="lpc-invalid">{`${t('runtimeNotice')}:${kit.versionNotice}`}</p>
85
96
  ) : null}
package/src/index.ts CHANGED
@@ -11,7 +11,10 @@
11
11
  *
12
12
  * 接线约束:inject 须含 credentials(auth-inline/deepseek-routes 经
13
13
  * ctx.get('credentials') 读 seam;凭据解析为请求时惰性调用,硬声明保证
14
- * 时序,消除启动早窗口的一次性 MISSING_CREDENTIAL)。
14
+ * 时序,消除启动早窗口的一次性 MISSING_CREDENTIAL)与 settings
15
+ * (应急副本手写条目来源:readManualProviders 经 ctx.settings.describe
16
+ * 读官方 llm-pi-ai 生效视图;漏声明即启动期 "cannot get property without
17
+ * inject" → 插件树加载失败)。
15
18
  * @module @dsh-plus/llm-pi
16
19
  */
17
20
  import type { Context } from '@deepseek-ai/cordis'
@@ -22,7 +25,7 @@ import { startRuntime } from './service.ts'
22
25
 
23
26
  export const name = 'dsh-plus-llm-pi'
24
27
 
25
- export const inject = ['credentials', 'llm'] as const
28
+ export const inject = ['credentials', 'llm', 'settings'] as const
26
29
 
27
30
  export type {
28
31
  LlmPiConfig,
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 官方应急副本的生成编排:装配(原始配置 → 物化 → 翻译)→ 逐 route 官方校验
3
+ * → 备忘跳过 / 原子落盘 → 状态供配置卡与 lifeboat 面板消费。
4
+ *
5
+ * 触发时机:插件启动、配置热更新(loader/volatile-update)、adapter 注册变化
6
+ * (llm/adapters-updated,官方 llm-pi-ai 晚注册时手写条目在此补入)。无定时器、
7
+ * 无 watcher——平时零开销;写失败只降级为状态与日志,绝不影响路由注册。
8
+ *
9
+ * 逐 route 官方校验是「副本必然官方可识别」的执行点:官方 Config schema 解析
10
+ * (非法协议/越界取值在此抛错)+ 官方 resolveProfiles 可服务性链;任一不过即
11
+ * 剔除该 route 并记 warning——写不进官方的东西绝不落盘。
12
+ * @module llm-pi/official-copy-writer
13
+ */
14
+ import { stat } from 'node:fs/promises'
15
+ import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
16
+ import { atomicWriteFile, isVolatileRef } from '@dsh-plus/shared'
17
+
18
+ import type { LlmPiConfig } from './config.ts'
19
+ import {
20
+ buildOfficialCopy,
21
+ deriveOfficialRouteFields,
22
+ FALLBACK_ROUTE_FIELDS,
23
+ OFFICIAL_COPY_PATH,
24
+ type OfficialCopy,
25
+ renderOfficialCopy,
26
+ } from './official-copy.ts'
27
+ import { normalizeOfficialRoutes } from './profiles.ts'
28
+ import { buildDeepseekRoutes } from './profiles-deepseek.ts'
29
+ import type { DshKit } from './resolve-dsh.ts'
30
+
31
+ /** 官方 llm-pi-ai 的 settings 命名空间(与插件 NS 同字面量,官方 0.1.x 起稳定)。 */
32
+ const NS_OFFICIAL_PI_AI = 'llm-pi-ai'
33
+
34
+ /** 副本状态(配置卡状态行与诊断的 JSON 面;updatedAt 缺省 = 尚未生成)。 */
35
+ export interface OfficialCopyStatus {
36
+ path: string
37
+ updatedAt?: number
38
+ routes: number
39
+ warnings: string[]
40
+ error?: string
41
+ }
42
+
43
+ /** 手写官方条目读取结果:providers 缺省 + warning 说明降级原因。 */
44
+ export interface ManualSourceResult {
45
+ providers?: Record<string, unknown>
46
+ warning?: string
47
+ }
48
+
49
+ /** settings 服务窄面(describe 的描述符子集;测试可注入固定值)。 */
50
+ export interface SettingsDescribeLike {
51
+ describe(): Array<{ ns: string; value?: unknown }>
52
+ }
53
+
54
+ /**
55
+ * 手写官方条目来源:settings describe 的 llm-pi-ai 生效视图(composition base
56
+ * = profile patch 行 + settings 用户层,schema 已解析、已过官方 validate)——
57
+ * 官方 seam,无需自行解析 patch 文件。ns 未注册/读取失败时降级为「仅本插件
58
+ * 改写条目」并附 warning,不阻断生成。
59
+ */
60
+ export function readManualProviders(settings: SettingsDescribeLike): ManualSourceResult {
61
+ try {
62
+ const row = settings.describe().find((entry) => entry.ns === NS_OFFICIAL_PI_AI)
63
+ if (row === undefined) {
64
+ return { warning: '官方 llm-pi-ai 未注册(插件缺席),副本仅含本插件改写条目' }
65
+ }
66
+ const raw = (row.value as { providers?: unknown } | undefined)?.providers
67
+ const providers = isVolatileRef(raw) ? raw.get() : raw
68
+ if (providers === null || typeof providers !== 'object') return {}
69
+ return { providers: providers as Record<string, unknown> }
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error)
72
+ return { warning: `读取官方 llm-pi-ai 生效配置失败(${message}),副本仅含本插件改写条目` }
73
+ }
74
+ }
75
+
76
+ export interface OfficialCopyWriterDeps {
77
+ kit: DshKit
78
+ /** 当前生效的本插件配置(volatile 已解包的平面快照)。 */
79
+ current: () => LlmPiConfig
80
+ /** 手写官方条目读取(生产接 readManualProviders(ctx.settings))。 */
81
+ readManual: () => ManualSourceResult
82
+ /** 来源 profile 名(头注释应用指引用)。 */
83
+ profile: string | undefined
84
+ /** 落盘路径(缺省 .dsh 根的 OFFICIAL_COPY_PATH;测试注入临时路径)。 */
85
+ path?: string
86
+ logger: { warn(message: unknown): void }
87
+ }
88
+
89
+ export interface OfficialCopyWriter {
90
+ /** 生成(或确认最新)副本:串行化执行,失败降级为状态,绝不抛出。 */
91
+ ensureOfficialCopy(): Promise<void>
92
+ /** 最近一次生成的状态。 */
93
+ status(): OfficialCopyStatus
94
+ }
95
+
96
+ /** 文件是否已存在(内容备忘跳过后自愈外部删除)。 */
97
+ async function fileExists(path: string): Promise<boolean> {
98
+ try {
99
+ return (await stat(path)).isFile()
100
+ } catch {
101
+ return false
102
+ }
103
+ }
104
+
105
+ /** 组装 + 翻译:返回含全部 warning 的副本内容(尚未官方校验)。 */
106
+ function assemble(deps: OfficialCopyWriterDeps, warnings: string[]): OfficialCopy {
107
+ const routeFields = deriveOfficialRouteFields(deps.kit.officialConfig)
108
+ if (routeFields === undefined) {
109
+ warnings.push('官方 Config schema 布局不可识别,route 字段回退内置键集')
110
+ }
111
+ const providers = deps.current().providers ?? {}
112
+ const normalizedPi = normalizeOfficialRoutes(providers, {
113
+ kit: deps.kit,
114
+ lenient: true,
115
+ warn: (message) => warnings.push(message),
116
+ })
117
+ const deepseekResolved = buildDeepseekRoutes(providers, {
118
+ kit: deps.kit,
119
+ lenient: true,
120
+ warn: (message) => warnings.push(message),
121
+ })
122
+ const manual = deps.readManual()
123
+ if (manual.warning !== undefined) warnings.push(manual.warning)
124
+ const copy = buildOfficialCopy({
125
+ providers,
126
+ normalizedPi,
127
+ deepseekResolved,
128
+ manualProviders: manual.providers,
129
+ routeFields: routeFields ?? [...FALLBACK_ROUTE_FIELDS],
130
+ modelFields: deps.kit.officialModelFields,
131
+ })
132
+ warnings.push(...copy.warnings)
133
+ return { providers: copy.providers, warnings }
134
+ }
135
+
136
+ /** 逐 route 官方校验:schema 解析 + 可服务性链;不过即剔除并记 warning。 */
137
+ function acceptOfficial(
138
+ deps: OfficialCopyWriterDeps,
139
+ copy: OfficialCopy,
140
+ warnings: string[],
141
+ ): Record<string, unknown> {
142
+ const accepted: Record<string, unknown> = {}
143
+ for (const [route, profile] of Object.entries(copy.providers)) {
144
+ try {
145
+ deps.kit.officialConfig({ providers: { [route]: profile } })
146
+ deps.kit.resolveProfiles({ [route]: profile as PiAiProviderProfile })
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error)
149
+ warnings.push(`route "${route}" 未通过官方校验,已从副本剔除:${message}`)
150
+ continue
151
+ }
152
+ accepted[route] = profile
153
+ }
154
+ return accepted
155
+ }
156
+
157
+ /** 生成态:内容备忘(跳过同内容重写)与最近生成时间(mtime 语义)。 */
158
+ interface CopyState {
159
+ lastContent?: string
160
+ updatedAt?: number
161
+ }
162
+
163
+ /** 落盘:内容未变且文件仍在则跳过(并自愈外部删除);返回最近生成时间。 */
164
+ async function persist(path: string, content: string, state: CopyState): Promise<number> {
165
+ if (content === state.lastContent && (await fileExists(path)))
166
+ return state.updatedAt ?? Date.now()
167
+ await atomicWriteFile(path, content)
168
+ state.lastContent = content
169
+ state.updatedAt = Date.now()
170
+ return state.updatedAt
171
+ }
172
+
173
+ /** 生成一次副本(错误上抛,由调用方降级为 status.error)。 */
174
+ async function runOnce(
175
+ deps: OfficialCopyWriterDeps,
176
+ path: string,
177
+ state: CopyState,
178
+ ): Promise<OfficialCopyStatus> {
179
+ const warnings: string[] = []
180
+ const assembled = assemble(deps, warnings)
181
+ const providers = acceptOfficial(deps, assembled, warnings)
182
+ const content = renderOfficialCopy({ providers, warnings }, { profile: deps.profile })
183
+ const updatedAt = await persist(path, content, state)
184
+ return { path, updatedAt, routes: Object.keys(providers).length, warnings }
185
+ }
186
+
187
+ /** 装配写入器:启动/热更新/adapter 事件驱动,串行执行(promise 链)。 */
188
+ export function createOfficialCopyWriter(deps: OfficialCopyWriterDeps): OfficialCopyWriter {
189
+ const path = deps.path ?? OFFICIAL_COPY_PATH
190
+ const state: CopyState = {}
191
+ let status: OfficialCopyStatus = { path, routes: 0, warnings: [] }
192
+ let chain: Promise<void> = Promise.resolve()
193
+ const ensureOfficialCopy = (): Promise<void> => {
194
+ chain = chain.then(async () => {
195
+ try {
196
+ status = await runOnce(deps, path, state)
197
+ } catch (error) {
198
+ // 落盘/装配失败:磁盘上仍是上一次成功的副本,保留其状态并标注错误。
199
+ const message = error instanceof Error ? error.message : String(error)
200
+ deps.logger.warn(`llm-pi: 应急副本生成失败:${message}`)
201
+ status = { ...status, error: message }
202
+ }
203
+ })
204
+ return chain
205
+ }
206
+ return { ensureOfficialCopy, status: () => status }
207
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * 官方应急副本的翻译与渲染(纯函数,零 IO)。
3
+ *
4
+ * 产出一份「官方可识别」的 profile patch 覆盖层:把 dsh-plus-llm-pi 的 route
5
+ * 配置改写为官方 `llm-pi-ai` 的 Config 形状,另附一行禁用 dsh-plus-llm-pi 的
6
+ * 覆盖(应用即切换,同名 route 不撞 DUPLICATE_ADAPTER)。写盘、逐 route 官方
7
+ * 校验与状态管理见 official-copy-writer.ts。
8
+ *
9
+ * 三条翻译底线(对旧「出错自动改写」失效教训的根治):
10
+ * 1. **只产出官方 schema 声明的字段**:route 键集从官方 Config schema 现场推导
11
+ * (失败回退内置键集),模型条目按 kit.officialModelFields 过滤——官方拒收
12
+ * 的字段根本不出现在副本里;
13
+ * 2. **compat 只留 offer**:withhold 与未知键按生效门控表丢弃(目录继承值可能
14
+ * 携带 withhold 键,写时会被官方拒绝);
15
+ * 3. **继承全物化**:官方无 extends——模型条目来自 normalizeRoute 的全显式物化
16
+ * 产物(单协议、单端点约束由该链保证,无需拆分 route)。
17
+ *
18
+ * deepseek 路由改写为 openai-completions(中继网关本就是 OpenAI 兼容面):
19
+ * 文件通道与思考配置不迁移(官方 llm-pi-ai 无对应字段/无逐模型 reasoningEfforts),
20
+ * 以 warning 明示,绝不静默丢弃。
21
+ * @module llm-pi/official-copy
22
+ */
23
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
24
+ import type { DeepSeekCatalogModel } from '@deepseek-ai/dsh-llm-deepseek'
25
+ import type { PiAiModelProfile, PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
26
+ import { stringify } from 'yaml'
27
+
28
+ import { compatDispositionOf } from './compat.ts'
29
+ import type { ProviderProfileConfig } from './config.ts'
30
+ import type { SchemaNode } from './official-surface.ts'
31
+ import { isDraftRoute } from './profiles.ts'
32
+ import type { ResolvedDeepseekRoute } from './profiles-deepseek.ts'
33
+ import type { OfficialConfigSchema } from './resolve-dsh.ts'
34
+
35
+ /** 副本文件在 Harness home(.dsh)根目录的落点;lifeboat 面板按同一路径消费(文件契约)。 */
36
+ export const OFFICIAL_COPY_PATH = dshHomePath('llm-pi.official-patch.yaml')
37
+
38
+ /** deepseek 路由改写所用线协议:中继网关对外提供 OpenAI 兼容 chat completions 面。 */
39
+ export const DEEPSEEK_COPY_API = 'openai-completions'
40
+
41
+ /**
42
+ * 官方 Config schema 布局不可识别时的 route 字段兜底(官方 PiAiProviderProfile
43
+ * 最后已知键集)。与官方新增字段的失配只表现为「新字段不进副本」,不弄挂生成。
44
+ */
45
+ export const FALLBACK_ROUTE_FIELDS = [
46
+ 'apiKeyEnv',
47
+ 'displayName',
48
+ 'api',
49
+ 'baseURL',
50
+ 'models',
51
+ 'modelOverrides',
52
+ 'compat',
53
+ 'defaultContextWindow',
54
+ 'defaultMaxTokens',
55
+ 'defaultInput',
56
+ 'headers',
57
+ 'reasoning',
58
+ 'thinkingBudgets',
59
+ 'cacheRetention',
60
+ 'transport',
61
+ 'timeoutMs',
62
+ 'websocketConnectTimeoutMs',
63
+ 'streamIdleTimeoutMs',
64
+ 'maxRequestImageBytes',
65
+ 'requestImagePixelBudget',
66
+ 'requestImageMaxBytes',
67
+ 'retryPolicy',
68
+ ] as const
69
+
70
+ /** deepseek 专有且官方 llm-pi-ai 无对应字段的 raw 键(出现即记 warning)。 */
71
+ const DEEPSEEK_UNMIGRATED_FIELDS = [
72
+ 'thinking',
73
+ 'reasoningEffort',
74
+ 'maxRequestFilesBytes',
75
+ 'filesApiTimeoutMs',
76
+ 'fileExpiresAfterSeconds',
77
+ 'fileRefreshMarginSeconds',
78
+ 'maxImagesPerRequest',
79
+ 'imageOffloadByteQuantum',
80
+ 'inlineImageOffloadByteQuantum',
81
+ 'imageOffloadCountQuantum',
82
+ ] as const
83
+
84
+ /**
85
+ * 从官方 Config schema 推导 route 字段键集(`providers.*` 的 dict 键);
86
+ * 布局不可识别/键集为空返回 undefined,调用方回退内置键集并告警。
87
+ */
88
+ export function deriveOfficialRouteFields(config: OfficialConfigSchema): string[] | undefined {
89
+ const fields = (config as unknown as SchemaNode).dict?.['providers']?.inner?.dict
90
+ if (fields === undefined) return undefined
91
+ const keys = Object.keys(fields)
92
+ return keys.length > 0 ? keys : undefined
93
+ }
94
+
95
+ /** compat 过滤:只保留该协议 offer 的字段(withhold/未知键丢弃);无保留项返回 undefined。 */
96
+ function offerCompat(
97
+ api: string,
98
+ compat: Record<string, unknown> | undefined,
99
+ ): Record<string, unknown> | undefined {
100
+ if (compat === undefined || typeof compat !== 'object') return undefined
101
+ const out: Record<string, unknown> = {}
102
+ for (const [field, value] of Object.entries(compat)) {
103
+ if (value === undefined || value === null) continue
104
+ if (compatDispositionOf(api, field) === 'offer') out[field] = value
105
+ }
106
+ return Object.keys(out).length > 0 ? out : undefined
107
+ }
108
+
109
+ /** 单个模型条目 → 官方键集内的条目(compat 另过 offer 门控)。 */
110
+ function filterModel(
111
+ api: string,
112
+ model: PiAiModelProfile,
113
+ modelFields: readonly string[],
114
+ ): Record<string, unknown> {
115
+ const source = model as unknown as Record<string, unknown>
116
+ const out: Record<string, unknown> = {}
117
+ for (const key of modelFields) {
118
+ const value = source[key]
119
+ if (value === undefined) continue
120
+ if (key === 'compat') {
121
+ const compat = offerCompat(api, value as Record<string, unknown>)
122
+ if (compat !== undefined) out.compat = compat
123
+ continue
124
+ }
125
+ out[key] = value
126
+ }
127
+ return out
128
+ }
129
+
130
+ /** 归一化 profile → 官方 route 字段内的条目(models 逐条过滤)。 */
131
+ function filterRoute(
132
+ profile: PiAiProviderProfile,
133
+ routeFields: readonly string[],
134
+ modelFields: readonly string[],
135
+ ): Record<string, unknown> {
136
+ const source = profile as unknown as Record<string, unknown>
137
+ const api = typeof source['api'] === 'string' ? source['api'] : ''
138
+ const out: Record<string, unknown> = {}
139
+ for (const key of routeFields) {
140
+ const value = source[key]
141
+ if (value === undefined) continue
142
+ if (key === 'models') {
143
+ const models = Array.isArray(value) ? (value as PiAiModelProfile[]) : []
144
+ out.models = models.map((model) => filterModel(api, model, modelFields))
145
+ continue
146
+ }
147
+ out[key] = value
148
+ }
149
+ return out
150
+ }
151
+
152
+ /** deepseek 单模型 → 官方条目(inputModalities 改名 input;图像预算不迁移)。 */
153
+ function deepseekModelEntry(model: DeepSeekCatalogModel): Record<string, unknown> {
154
+ const out: Record<string, unknown> = { id: model.id }
155
+ if (model.name !== undefined) out.name = model.name
156
+ if (model.contextWindow !== undefined) out.contextWindow = model.contextWindow
157
+ if (model.maxTokens !== undefined) out.maxTokens = model.maxTokens
158
+ if (model.inputModalities !== undefined) out.input = [...model.inputModalities]
159
+ return out
160
+ }
161
+
162
+ interface DeepseekCopyResult {
163
+ profile: Record<string, unknown>
164
+ /** 未迁移的字段名(出现即入副本头 warning,绝不静默丢弃)。 */
165
+ unmirrored: string[]
166
+ }
167
+
168
+ /**
169
+ * deepseek 路由 → 官方 llm-pi-ai profile:连接事实取 resolveAdapterOptions 的
170
+ * 校验产物(端点/凭据引用/限额),模型取官方目录物化结果。思考配置与文件通道
171
+ * 字段不迁移(llm-pi-ai 无对应语义),逐项列入 unmirrored。
172
+ */
173
+ function deepseekCopyProfile(
174
+ raw: ProviderProfileConfig,
175
+ resolved: ResolvedDeepseekRoute,
176
+ ): DeepseekCopyResult {
177
+ const connection = resolved.connection
178
+ const unmirrored: string[] = DEEPSEEK_UNMIGRATED_FIELDS.filter((key) => raw[key] !== undefined)
179
+ if (raw.models?.some((m) => m.imagePixelBudget !== undefined || m.imageMaxBytes !== undefined))
180
+ unmirrored.push('模型级 imagePixelBudget/imageMaxBytes')
181
+ const profile: Record<string, unknown> = {
182
+ displayName: resolved.displayName,
183
+ api: DEEPSEEK_COPY_API,
184
+ baseURL: connection.baseURL,
185
+ apiKeyEnv: raw.apiKeyEnv,
186
+ defaultContextWindow: connection.defaultContextWindow,
187
+ defaultMaxTokens: connection.maxTokens,
188
+ streamIdleTimeoutMs: connection.streamIdleTimeoutMs,
189
+ models: connection.models.map(deepseekModelEntry),
190
+ }
191
+ if (raw.retryPolicy !== undefined) profile.retryPolicy = raw.retryPolicy
192
+ // 语义对应:deepseek 的 base64 降级后载荷上限 ↔ llm-pi-ai 的单请求 base64 上限。
193
+ if (raw.maxInlineRequestImageBytes !== undefined) {
194
+ profile.maxRequestImageBytes = connection.maxInlineRequestImageBytes
195
+ }
196
+ return { profile, unmirrored }
197
+ }
198
+
199
+ /** 翻译输入:运行期物化产物 + 原始配置 + 官方生效的手写条目。 */
200
+ export interface OfficialCopyInput {
201
+ /** 本插件当前原始配置(不受 enabled 影响——副本是配置层事实)。 */
202
+ providers: Record<string, ProviderProfileConfig>
203
+ /** normalizeOfficialRoutes 产物:pi route 的官方形状物化结果。 */
204
+ normalizedPi: ReadonlyMap<string, PiAiProviderProfile>
205
+ /** buildDeepseekRoutes 产物:deepseek route 的连接事实。 */
206
+ deepseekResolved: ReadonlyMap<string, ResolvedDeepseekRoute>
207
+ /** 官方 llm-pi-ai 生效 providers(settings describe 视图,含 profile 手写条目)。 */
208
+ manualProviders: Record<string, unknown> | undefined
209
+ /** 官方 route 字段键集(deriveOfficialRouteFields / FALLBACK_ROUTE_FIELDS)。 */
210
+ routeFields: readonly string[]
211
+ /** 官方模型条目字段键集(kit.officialModelFields,现场推导)。 */
212
+ modelFields: readonly string[]
213
+ }
214
+
215
+ export interface OfficialCopy {
216
+ /** route 键 → 官方 profile(已过滤;手写条目覆盖同名 key)。空 = 空补丁。 */
217
+ providers: Record<string, unknown>
218
+ /** 生成期警告(渲染进副本头注释,也进配置卡诊断)。 */
219
+ warnings: string[]
220
+ }
221
+
222
+ /** 翻译整份配置为官方副本内容(纯函数;坏 route 的跳过告警由上游 warn 通道汇集)。 */
223
+ export function buildOfficialCopy(input: OfficialCopyInput): OfficialCopy {
224
+ const warnings: string[] = []
225
+ const providers: Record<string, unknown> = {}
226
+ for (const [route, raw] of Object.entries(input.providers)) {
227
+ if ((raw.adapter ?? 'pi') === 'deepseek') {
228
+ const resolved = input.deepseekResolved.get(route)
229
+ if (resolved === undefined) continue // 物化失败:buildDeepseekRoutes 已告警
230
+ const { profile, unmirrored } = deepseekCopyProfile(raw, resolved)
231
+ providers[route] = profile
232
+ if (unmirrored.length > 0) {
233
+ warnings.push(
234
+ `deepseek route "${route}" 的字段未迁移到 llm-pi-ai:${unmirrored.join('、')}`,
235
+ )
236
+ }
237
+ continue
238
+ }
239
+ const normalized = input.normalizedPi.get(route)
240
+ if (normalized === undefined) {
241
+ // 物化失败的 route 由 normalizeOfficialRoutes 的 warn 通道报告;草稿
242
+ // (未配置模型的占位)本就不可服务,在此补一条可读说明。
243
+ if (isDraftRoute(raw)) warnings.push(`草稿 route "${route}" 未纳入副本(尚无模型)`)
244
+ continue
245
+ }
246
+ providers[route] = filterRoute(normalized, input.routeFields, input.modelFields)
247
+ }
248
+ // 手写官方条目(describe 的生效视图)同名 key 手写优先:那是用户的显式意图。
249
+ for (const [route, profile] of Object.entries(input.manualProviders ?? {})) {
250
+ if (profile !== null && typeof profile === 'object') providers[route] = profile
251
+ }
252
+ return { providers, warnings }
253
+ }
254
+
255
+ /** 渲染元数据(头注释里的应用指引)。 */
256
+ export interface OfficialCopyMeta {
257
+ /** 来源 profile 名(缺省以占位符渲染应用命令)。 */
258
+ profile: string | undefined
259
+ }
260
+
261
+ /** 头注释:用途、两种应用方式、切换语义、回退方法与警告清单(确定性输出,mtime 即生成时间)。 */
262
+ function renderHeader(copy: OfficialCopy, meta: OfficialCopyMeta): string {
263
+ const profile = meta.profile ?? '<profile>'
264
+ return [
265
+ '# 官方应急 LLM 配置副本 —— @dsh-plus/llm-pi 自动生成(勿手改:下次配置变更会被覆盖;文件 mtime 即最近生成时间)',
266
+ `# 来源 profile:${profile}`,
267
+ '# 用途:dsh-plus-llm-pi 缺席/故障时,用本文件把其 route 以官方 llm-pi-ai 配置恢复 LLM 能力。',
268
+ '# 应用(二选一,重启/重载后生效):',
269
+ `# 1) 单次启动:dsh ${profile} --patch ${OFFICIAL_COPY_PATH}`,
270
+ `# 2) 长期生效:把本文件条目并入该 profile 的 cordis.patch.yml(~/.dsh/profiles/${profile}/)`,
271
+ '# 语义:应用即切换 —— 副本禁用 dsh-plus-llm-pi,并整行替换 llm-pi-ai 的 config',
272
+ '# (官方 llm-pi-ai 现有 providers 已合并保留;同名 key 以手写条目优先)。',
273
+ '# 回退:移除 --patch 参数或删除并入的条目(含 dsh-plus-llm-pi 的 disabled 行),重启/重载。',
274
+ `# 本次内容:${Object.keys(copy.providers).length} 个官方 provider route。`,
275
+ ...copy.warnings.map((warning) => `# 警告:${warning}`),
276
+ ].join('\n')
277
+ }
278
+
279
+ /**
280
+ * 渲染副本文件内容(确定性:同输入同字节,mtime 承载时间语义)。
281
+ * 零 route 时渲染空补丁列表 `[]`(应用无效果)——绝不产出「只禁用不供给」的误导性副本。
282
+ */
283
+ export function renderOfficialCopy(copy: OfficialCopy, meta: OfficialCopyMeta): string {
284
+ const header = renderHeader(copy, meta)
285
+ if (Object.keys(copy.providers).length === 0) {
286
+ return `${header}\n# 当前没有可迁移的 route(llm-pi 未配置或全部不可服务),空补丁应用无效果。\n[]\n`
287
+ }
288
+ const rows = [
289
+ { id: 'dsh-plus-llm-pi', disabled: true },
290
+ { id: 'llm-pi-ai', config: { providers: copy.providers } },
291
+ ]
292
+ return `${header}\n${stringify(rows, { lineWidth: 0 })}`
293
+ }
package/src/profiles.ts CHANGED
@@ -710,6 +710,38 @@ export function buildProfiles(
710
710
  return resolved
711
711
  }
712
712
 
713
+ /**
714
+ * 应急副本用:逐 route 归一化为官方 schema 形状的 profile(与运行期同一条
715
+ * normalizeRoute 链:extends 物化、单协议单端点约束、模型条目全显式)。
716
+ *
717
+ * 与运行期 buildProfiles 的两点差异:
718
+ * - 不受 `enabled` 开关影响——副本是配置层事实,插件关闭时其 route 仍应可复制;
719
+ * - 逐 route 隔离失败(不依赖 lenient 开关)——一个坏条目只丢它自己,
720
+ * 不让整份应急副本变空。
721
+ *
722
+ * adapter: deepseek 的 route 与草稿路由不在此列(前者由 profiles-deepseek
723
+ * 物化、后者本就不可服务),调用方按缺席另行告警。
724
+ */
725
+ export function normalizeOfficialRoutes(
726
+ providers: Record<string, ProviderProfileConfig> | undefined,
727
+ deps: BuildDeps,
728
+ ): Map<string, PiAiProviderProfile> {
729
+ const out = new Map<string, PiAiProviderProfile>()
730
+ for (const [route, profile] of Object.entries(providers ?? {})) {
731
+ try {
732
+ const normalized = normalizeRoute(route, profile, deps)
733
+ if (normalized !== undefined) out.set(route, normalized.profile)
734
+ } catch (error) {
735
+ deps.warn?.(
736
+ `llm-pi: provider "${route}" 无法物化为官方形状(${
737
+ error instanceof Error ? error.message : String(error)
738
+ }),已跳过应急副本的该 route`,
739
+ )
740
+ }
741
+ }
742
+ return out
743
+ }
744
+
713
745
  /** settings 写入校验钩子:完整试跑解析,非法配置在写入处拒绝。 */
714
746
  export function assertServiceable(
715
747
  config: { providers?: Record<string, ProviderProfileConfig> },
@@ -93,6 +93,13 @@ export type ResolveProfiles = (
93
93
  providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
94
94
  ) => Map<string, ResolvedPiAiProviderProfile>
95
95
 
96
+ /**
97
+ * 官方 Config schema 的调用面(根导出 `Config`,schemastery schema 的可调用形态):
98
+ * 应急副本写前校验「官方是否接受这个形状」——非法协议/取值越界在此抛错。
99
+ * 只用其抛错语义,解析产物(volatile 视图)不消费。
100
+ */
101
+ export type OfficialConfigSchema = (value: unknown) => unknown
102
+
96
103
  /** PiAiAdapter 必需 auth 注入的两个助手(官方 auth.ts 面;包根未导出)。 */
97
104
  export interface AuthHelpers {
98
105
  credentialStoreFrom(ctx: Context): CredentialStore
@@ -117,6 +124,11 @@ export interface DshKit {
117
124
  /** 内置目录数据生成时间(毫秒);官方未导出该信息时省略。 */
118
125
  catalogGeneratedAt?: number
119
126
  PiAiAdapter: typeof PiAiAdapterType
127
+ /**
128
+ * 官方 `Config` schema(与 PiAiAdapter 同一模块根导出,同源取用):
129
+ * 应急副本落盘前的官方可识别性校验(见 {@link OfficialConfigSchema})。
130
+ */
131
+ officialConfig: OfficialConfigSchema
120
132
  LlmError: typeof DshLlm.LlmError
121
133
  resolveRetryPolicy: typeof DshLlm.resolveRetryPolicy
122
134
  attributionHeaders: typeof DshLlm.attributionHeaders
@@ -173,6 +185,7 @@ function assertKitShape(kit: DshKit, origin: string): void {
173
185
  }
174
186
  }
175
187
  if (typeof kit.createProvider !== 'function') problems.push('pi-ai createProvider 缺失')
188
+ if (typeof kit.officialConfig !== 'function') problems.push('dsh-llm-pi-ai Config schema 缺失')
176
189
  if (typeof kit.getBuiltinModels !== 'function') problems.push('pi-ai getBuiltinModels 缺失')
177
190
  if (typeof kit.builtinProviders !== 'function') problems.push('pi-ai builtinProviders 缺失')
178
191
  if (kit.protocols.length === 0) problems.push('没有可服务的线协议(协议工厂全部加载失败)')
@@ -529,6 +542,7 @@ function kitFromTree(options: {
529
542
  ? {}
530
543
  : { catalogGeneratedAt: surface.catalogGeneratedAt }),
531
544
  PiAiAdapter: mods.piAiAdapter['PiAiAdapter'] as DshKit['PiAiAdapter'],
545
+ officialConfig: mods.piAiAdapter['Config'] as DshKit['officialConfig'],
532
546
  LlmError: mods.llm['LlmError'] as DshKit['LlmError'],
533
547
  resolveRetryPolicy: mods.llm['resolveRetryPolicy'] as DshKit['resolveRetryPolicy'],
534
548
  attributionHeaders: mods.llm['attributionHeaders'] as DshKit['attributionHeaders'],
@@ -626,6 +640,7 @@ export function loadVendoredKit(diagnostics: string[] = []): DshKit {
626
640
  ? {}
627
641
  : { catalogGeneratedAt: surface.catalogGeneratedAt }),
628
642
  PiAiAdapter: vendoredPiAiAdapter.PiAiAdapter,
643
+ officialConfig: vendoredPiAiAdapter.Config as DshKit['officialConfig'],
629
644
  LlmError: vendoredLlm.LlmError,
630
645
  resolveRetryPolicy: vendoredLlm.resolveRetryPolicy,
631
646
  attributionHeaders: vendoredLlm.attributionHeaders,
package/src/service.ts CHANGED
@@ -40,6 +40,11 @@ import { DeepseekRouteRegistrar } from './deepseek-routes.ts'
40
40
  import { buildDirectoryEntries, commitDirectory, type DirectoryEntry } from './directory.ts'
41
41
  import { discoverModels } from './discovery.ts'
42
42
  import { type KitVersions, piAiVersionNotice, VERIFIED_PI_AI_RANGE } from './kit-meta.ts'
43
+ import {
44
+ createOfficialCopyWriter,
45
+ type OfficialCopyStatus,
46
+ readManualProviders,
47
+ } from './official-copy-writer.ts'
43
48
  import { assertServiceable, buildProfiles, isDraftRoute } from './profiles.ts'
44
49
  import { buildDeepseekRoutes, type ResolvedDeepseekRoute } from './profiles-deepseek.ts'
45
50
  import { type DshKit, resolveDshKit } from './resolve-dsh.ts'
@@ -64,6 +69,8 @@ export interface LlmPiKitInfo {
64
69
  catalog: { providers: number; models: number; generatedAt?: number }
65
70
  /** compat 门控表来源:official / fallback。 */
66
71
  compatSource: string
72
+ /** 官方应急副本状态(路径/生成时间/覆盖 route/警告;error = 最近生成失败)。 */
73
+ officialCopy: OfficialCopyStatus
67
74
  /** 回退与逐项降级诊断(有内容时界面显式展示)。 */
68
75
  diagnostics: string[]
69
76
  }
@@ -412,9 +419,20 @@ export async function startRuntime(
412
419
  })
413
420
  const ensureDeepseek = (): void => deepseekRegistrar.sync(deepseekRoutes())
414
421
 
422
+ // 官方应急副本:启动即生成一次;热更新与 adapter 注册变化(官方 llm-pi-ai
423
+ // 晚注册时手写条目在此补入)再生成。写失败只降级为状态,不影响路由注册。
424
+ const copyWriter = createOfficialCopyWriter({
425
+ kit,
426
+ current,
427
+ readManual: () => readManualProviders(ctx.settings),
428
+ profile: ctx.get('profileContext')?.name,
429
+ logger,
430
+ })
431
+
415
432
  ensureRegistration()
416
433
  ensureDeepseek()
417
434
  ensureDirectory()
435
+ void copyWriter.ensureOfficialCopy()
418
436
 
419
437
  // 0.1.7 替代 installSection(validate/setSource/onChange 三钩子):
420
438
  // - 活动引用原位提交 → volatile-update 触发快照换代与重注册(原 onChange 体);
@@ -425,8 +443,12 @@ export async function startRuntime(
425
443
  attempt(logger, 'llm-pi: 更新被拒,保留此前注册的 route', ensureRegistration)
426
444
  attempt(logger, 'llm-pi: deepseek route 更新失败,保留此前注册', ensureDeepseek)
427
445
  attempt(logger, 'llm-pi: 更新被拒,保留此前的 configurable-provider 目录', ensureDirectory)
446
+ void copyWriter.ensureOfficialCopy()
428
447
  }
429
448
  ctx.events.on('loader/volatile-update', reconfigure)
449
+ // 官方 llm-pi-ai 晚于本插件注册时,describe 的手写条目视图此时才完整——
450
+ // adapter 集变化即重新生成副本(无 provider 变化时内容未变、跳过写盘)。
451
+ ctx.root.on('llm/adapters-updated', () => void copyWriter.ensureOfficialCopy())
430
452
  ctx.on('internal/config', function (_raw: unknown, next: () => unknown) {
431
453
  const candidate = next()
432
454
  if (this !== ctx.fiber) return candidate
@@ -439,6 +461,7 @@ export async function startRuntime(
439
461
  const providers = listProviders(kit)
440
462
  const generatedAt = kit.catalogGeneratedAt
441
463
  const notice = piAiVersionNotice(kit.versions.piAi)
464
+ const copy = copyWriter.status()
442
465
  return {
443
466
  source: kit.source,
444
467
  ...(kit.root === undefined ? {} : { root: kit.root }),
@@ -453,7 +476,12 @@ export async function startRuntime(
453
476
  ...(generatedAt === undefined ? {} : { generatedAt }),
454
477
  },
455
478
  compatSource: compatTableInfo().source,
456
- diagnostics,
479
+ officialCopy: copy,
480
+ diagnostics: [
481
+ ...diagnostics,
482
+ ...copy.warnings.map((warning) => `应急副本:${warning}`),
483
+ ...(copy.error === undefined ? [] : [`应急副本:生成失败(${copy.error})`]),
484
+ ],
457
485
  }
458
486
  }
459
487