@dsh-plus/llm-pi 0.1.45 → 0.2.1

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.
@@ -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
+ }
@@ -10,7 +10,7 @@
10
10
  * 3. **模型条目字段集**:Config schema 的 `providers.*.models` 键集——继承时按此
11
11
  * 透传 pi-ai 目录的同名字段,官方将来新增模型级字段即自动跟随。
12
12
  *
13
- * 历史教训(0.1.5-rc.1):compat 门控曾是官方 catalog.ts 的手抄镜像,官方扩容
13
+ * 历史教训(详见 docs/repo/事故记录.md):compat 门控曾是官方的手抄镜像,官方扩容
14
14
  * offer 字段而旧表漏收,导致官方可配字段被本插件**误拒**(静默功能缺失、无报错)。
15
15
  * 现改为现场推导,`tests/official-surface.test.ts` 直读官方副本守门推导正确性。
16
16
  *
@@ -205,10 +205,20 @@ export function fallbackProtocols(): string[] {
205
205
  return [...FALLBACK_PROTOCOLS]
206
206
  }
207
207
 
208
- /** 官方门控表不可解析时的最后已知快照(0.1.5-rc.2 实测值)。 */
208
+ /**
209
+ * 官方门控表不可解析时的兜底快照(0.2.1-alpha.1 现场推导值)。
210
+ *
211
+ * 只在官方改打包形态、`COMPAT_GATES` 解析失败时启用(此时未知键放行、只拦快照里
212
+ * 明确 withhold 的键,见 compat.ts 的「语义分两档」)。快照过期会重新引入
213
+ * 「误拒官方新字段」那类静默故障,故由 tests/official-surface.test.ts 的
214
+ * 「兜底快照不得过期」用例与现场推导逐协议比对守门。
215
+ */
209
216
  export const FALLBACK_TABLE: CompatTable = {
210
217
  source: 'fallback',
211
218
  gates: {
219
+ 'mistral-conversations': {
220
+ supportsMidConvoSystemMessages: 'withhold',
221
+ },
212
222
  'openai-completions': {
213
223
  supportsStore: 'offer',
214
224
  supportsDeveloperRole: 'offer',
@@ -234,7 +244,8 @@ export const FALLBACK_TABLE: CompatTable = {
234
244
  zaiToolStream: 'withhold',
235
245
  supportsOpenAIGrammarTools: 'withhold',
236
246
  sendSessionAffinityHeaders: 'withhold',
237
- deferredToolsMode: 'withhold',
247
+ supportsMidConvoSystemMessages: 'withhold',
248
+ supportsMidConvoToolAdditions: 'withhold',
238
249
  sessionAffinityFormat: 'withhold',
239
250
  },
240
251
  'openai-responses': {
@@ -247,6 +258,31 @@ export const FALLBACK_TABLE: CompatTable = {
247
258
  supportsAdditionalTools: 'withhold',
248
259
  supportsToolSearch: 'withhold',
249
260
  supportsExplicitPromptCacheMode: 'withhold',
261
+ supportsMidConvoSystemMessages: 'withhold',
262
+ },
263
+ 'azure-openai-responses': {
264
+ supportsDeveloperRole: 'offer',
265
+ supportsMaxOutputTokens: 'offer',
266
+ supportsStrictMode: 'offer',
267
+ supportsLongCacheRetention: 'offer',
268
+ sessionAffinityFormat: 'withhold',
269
+ supportsOpenAIGrammarTools: 'withhold',
270
+ supportsAdditionalTools: 'withhold',
271
+ supportsToolSearch: 'withhold',
272
+ supportsExplicitPromptCacheMode: 'withhold',
273
+ supportsMidConvoSystemMessages: 'withhold',
274
+ },
275
+ 'openai-codex-responses': {
276
+ supportsDeveloperRole: 'offer',
277
+ supportsMaxOutputTokens: 'offer',
278
+ supportsStrictMode: 'offer',
279
+ supportsLongCacheRetention: 'offer',
280
+ sessionAffinityFormat: 'withhold',
281
+ supportsOpenAIGrammarTools: 'withhold',
282
+ supportsAdditionalTools: 'withhold',
283
+ supportsToolSearch: 'withhold',
284
+ supportsExplicitPromptCacheMode: 'withhold',
285
+ supportsMidConvoSystemMessages: 'withhold',
250
286
  },
251
287
  'anthropic-messages': {
252
288
  supportsEagerToolInputStreaming: 'offer',
@@ -257,10 +293,15 @@ export const FALLBACK_TABLE: CompatTable = {
257
293
  allowEmptySignature: 'offer',
258
294
  supportsStrictTools: 'offer',
259
295
  sendSessionAffinityHeaders: 'withhold',
260
- supportsToolReferences: 'withhold',
296
+ sessionAffinityFormat: 'withhold',
297
+ supportsMidConvoSystemMessages: 'withhold',
298
+ supportsMidConvoToolChanges: 'withhold',
261
299
  supportsMidConvoEffort: 'withhold',
262
300
  allowedFallbackModels: 'withhold',
263
301
  },
302
+ 'bedrock-converse-stream': {
303
+ supportsStrictMode: 'offer',
304
+ },
264
305
  },
265
306
  specs: {
266
307
  supportsStore: 'boolean',
@@ -130,16 +130,6 @@ function materializeModel(
130
130
  if (entry.reasoningEfforts !== undefined || nonEmptyDict(entry.compat)) {
131
131
  invalid(route, `model "${entry.id}" 的 reasoningEfforts/compat 仅 adapter: pi 可用`)
132
132
  }
133
- const legacyImageDetail = (entry as ModelEntryConfig & { imageDetail?: unknown }).imageDetail
134
- if (legacyImageDetail !== undefined) {
135
- // 0.1.2-alpha.1 移除字段:写时明确拒绝并给出迁移提示(官方 llm-deepseek
136
- // 对含 imageDetail 的目录模型同样直接抛错,index.ts:212-215)。
137
- invalid(
138
- route,
139
- `model "${entry.id}" 的 imageDetail 已随 0.1.2-alpha.1 移除;` +
140
- '请改用 imagePixelBudget/imageMaxBytes(低细节可配更小的 imagePixelBudget)并删除 imageDetail',
141
- )
142
- }
143
133
  const base = resolveOfficialBase(route, entry, deps)
144
134
  const input = declaredModalities(entry.input) ?? base.input
145
135
  const imageFields = {
@@ -233,7 +223,7 @@ function buildRoute(
233
223
  if (deps.kit.deepseek === undefined) {
234
224
  invalid(
235
225
  route,
236
- '使用 adapter: deepseek,但当前运行时套件不含 dsh-llm-deepseek(0.1.2-alpha.1 基线)',
226
+ '使用 adapter: deepseek,但当前运行时套件不含 dsh-llm-deepseek(本仓基线的必需套件)',
237
227
  )
238
228
  }
239
229
  rejectPiOnlyFields(route, profile)
package/src/profiles.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * profile 构建器(0.1.2-alpha.2 复核兼容):插件 Config → 官方 schema 形状的
2
+ * profile 构建器:插件 Config → 官方 schema 形状的
3
3
  * 「前置数据源归一化」→ 官方 resolveProfiles(或插件等价兜底)→
4
4
  * ResolvedPiAiProviderProfile。
5
5
  *
@@ -190,14 +190,6 @@ function materializeModel(
190
190
  defaultInput: ('text' | 'image')[],
191
191
  deps: BuildDeps,
192
192
  ): MaterializedEntry | null {
193
- const legacyImageDetail = (entry as ModelEntryConfig & { imageDetail?: unknown }).imageDetail
194
- if (legacyImageDetail !== undefined) {
195
- invalid(
196
- route,
197
- `model "${entry.id}" 的 imageDetail 已随 0.1.2-alpha.1 移除;` +
198
- '请改用 imagePixelBudget/imageMaxBytes(仅 adapter: deepseek)并删除 imageDetail',
199
- )
200
- }
201
193
  const api = profile.api ?? base.api ?? routeApi
202
194
  if (api === undefined) {
203
195
  if (deps.lenient) {
@@ -606,7 +598,7 @@ export function resolveProfilesFallback(
606
598
  // 模型物化:全显式条目 + route 级 api/baseURL(官方 resolveRouteModels 镜像,
607
599
  // 无目录继承——归一化已把继承值落定在条目里)
608
600
  const configuredMaxTokens = new Map<string, number>()
609
- // 官方 0.1.5-alpha.2(llm-pi-ai)adapter 读取 profile.modelErrors/catalogError:
601
+ // 官方 llm-pi-ai adapter 读取 profile.modelErrors/catalogError:
610
602
  // 本 fallback 对条目严格校验、坏条目整体拒绝(不产生"条目级不可服务"的
611
603
  // deferred 诊断),故补空表保持与官方 profile 形状一致——缺失会让
612
604
  // PiAiAdapter.modelOf 读 modelErrors.get 时以 TypeError 打崩。
@@ -710,6 +702,38 @@ export function buildProfiles(
710
702
  return resolved
711
703
  }
712
704
 
705
+ /**
706
+ * 应急副本用:逐 route 归一化为官方 schema 形状的 profile(与运行期同一条
707
+ * normalizeRoute 链:extends 物化、单协议单端点约束、模型条目全显式)。
708
+ *
709
+ * 与运行期 buildProfiles 的两点差异:
710
+ * - 不受 `enabled` 开关影响——副本是配置层事实,插件关闭时其 route 仍应可复制;
711
+ * - 逐 route 隔离失败(不依赖 lenient 开关)——一个坏条目只丢它自己,
712
+ * 不让整份应急副本变空。
713
+ *
714
+ * adapter: deepseek 的 route 与草稿路由不在此列(前者由 profiles-deepseek
715
+ * 物化、后者本就不可服务),调用方按缺席另行告警。
716
+ */
717
+ export function normalizeOfficialRoutes(
718
+ providers: Record<string, ProviderProfileConfig> | undefined,
719
+ deps: BuildDeps,
720
+ ): Map<string, PiAiProviderProfile> {
721
+ const out = new Map<string, PiAiProviderProfile>()
722
+ for (const [route, profile] of Object.entries(providers ?? {})) {
723
+ try {
724
+ const normalized = normalizeRoute(route, profile, deps)
725
+ if (normalized !== undefined) out.set(route, normalized.profile)
726
+ } catch (error) {
727
+ deps.warn?.(
728
+ `llm-pi: provider "${route}" 无法物化为官方形状(${
729
+ error instanceof Error ? error.message : String(error)
730
+ }),已跳过应急副本的该 route`,
731
+ )
732
+ }
733
+ }
734
+ return out
735
+ }
736
+
713
737
  /** settings 写入校验钩子:完整试跑解析,非法配置在写入处拒绝。 */
714
738
  export function assertServiceable(
715
739
  config: { providers?: Record<string, ProviderProfileConfig> },
@@ -19,7 +19,7 @@
19
19
  * 模型条目字段集同样现场推导(official-surface.ts)。生效版本只用于状态行显示,
20
20
  * 超出已验证区间也只是提示(kit-meta.ts),永不阻断。
21
21
  *
22
- * 0.1.2-alpha 线的新面(0.1.2-alpha.2 复核包根仍未导出、仅 src 子路径有):
22
+ * 仅 src 子路径导出、包根不导出的两块面:
23
23
  * - resolveProfiles(config.ts):官方解析链,dev 布局树可经 src 子路径复用;
24
24
  * - credentialStoreFrom/authContextFrom(auth.ts):PiAiAdapter 必需 auth 注入。
25
25
  * npm 发布形态(lib/index.js 单 bundle + 无 src/)两者都拿不到:dsh 树优先
@@ -76,7 +76,7 @@ import { type ResolverDeps, resolveProfilesFallback } from './profiles.ts'
76
76
 
77
77
  /**
78
78
  * deepseek 适配器模块表面(可选):dsh-llm-deepseek 的官方 DeepSeekAdapter
79
- * 与目录解析器 + 匿名用户 id。缺失(旧版 dsh 树或形状漂移)不拖垮核心
79
+ * 与目录解析器 + 匿名用户 id。缺失(形状漂移)不拖垮核心
80
80
  * 套件——deepseek 类 route 在构建期以明确错误拒绝,pi 路由不受影响。
81
81
  * 与核心套件强制同源(杜绝跨源模块混用:brand/LlmError 恒等性敏感)。
82
82
  */
@@ -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
@@ -149,8 +161,8 @@ function assertKitShape(kit: DshKit, origin: string): void {
149
161
  const problems: string[] = []
150
162
  if (typeof kit.PiAiAdapter !== 'function') problems.push('PiAiAdapter 不是类')
151
163
  else {
152
- // 0.1.2-alpha.2 的 LlmAdapter 抽象面:prepareCall/providerRetryPolicy 为
153
- // master 适配器必需 override;models.getModels/getModel 语义未变
164
+ // LlmAdapter 抽象面:prepareCall/providerRetryPolicy 为适配器必需 override;
165
+ // models.getModels/getModel 语义
154
166
  // (pi-ai 0.84.2 Models.getModel(provider,id)/getModels(provider),
155
167
  // 由同源 adapter 内部消费,本插件不直调)。
156
168
  for (const method of [
@@ -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
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * 运行时主逻辑:注册/热更新/发现(逐点对齐官方 dsh-llm-pi-ai apply 的模式)。
3
3
  *
4
- * - profiles 回调按原始 config 对象 identity 备忘;配置变更经 0.1.7
5
- * volatile 原位提交(loader/volatile-update 换代快照)传播,下一请求生效
4
+ * - profiles 回调按原始 config 对象 identity 备忘;配置变更经 volatile 原位提交
5
+ * (loader/volatile-update 换代快照)传播,下一请求生效
6
6
  * (adapter 快照按 profiles identity 失效);
7
7
  * - route 集或注册时捕获的事实(displayName/retryPolicy)变化 → 原子的
8
8
  * handle.replace 重注册;写入被校验拒绝时保留旧注册(官方同款护栏);
@@ -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
  }
@@ -194,8 +201,8 @@ function createAdapter(options: {
194
201
  profiles,
195
202
  resolveApiKey: makeResolveApiKey(ctx, kit),
196
203
  resolveAttachments: () => ctx.get('attachments'),
197
- // 0.1.2-alpha.2 必需(包根仍未导出官方 auth 助手):登录/OAuth 类 provider 与
198
- // pi-ai 自有凭据写入的落点。dsh 树 dev 布局(src/auth.ts 存在)时为官方助手,
204
+ // 官方 auth 助手只在 src 子路径导出:登录/OAuth 类 provider 与 pi-ai 自有
205
+ // 凭据写入的落点。dsh 树 dev 布局(src/auth.ts 存在)时为官方助手,
199
206
  // 其余形态为内联等价实现(resolve-dsh.ts 探测,见 auth-inline.ts)。
200
207
  auth: {
201
208
  credentials: kit.auth.credentialStoreFrom(ctx),
@@ -365,7 +372,7 @@ export async function startRuntime(
365
372
  ): Promise<LlmPiRuntime> {
366
373
  const logger = ctx.logger('llm-pi')
367
374
  // cordis 行级 config 可能未经 schema 解析(insert 行无 config 键时为原始空对象),
368
- // 在此统一规范化;0.1.7 loader 解析出的 volatile 活动字段形态直接透传
375
+ // 在此统一规范化;loader 解析出的 volatile 活动字段形态直接透传
369
376
  // (hasVolatileRefs 辨识——重复校验会把引用再包一层、破坏 loader 原位提交)。
370
377
  const fields: LlmPiConfigFields = hasVolatileRefs(rawConfig)
371
378
  ? (rawConfig as LlmPiConfigFields)
@@ -412,11 +419,22 @@ 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
- // 0.1.7 替代 installSection(validate/setSource/onChange 三钩子):
437
+ // 替代 installSection(validate/setSource/onChange 三钩子):
420
438
  // - 活动引用原位提交 → volatile-update 触发快照换代与重注册(原 onChange 体);
421
439
  // - internal/config waterfall 承接写入校验(原 validate;官方 llm-pi-ai
422
440
  // 同款):configEditor.edit 落盘前先跑 waterfall,handler throw 即拒绝整笔写入。
@@ -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