@dsh-plus/llm-pi 0.1.13 → 0.1.14

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/src/profiles.ts CHANGED
@@ -1,18 +1,37 @@
1
1
  /**
2
- * profile 构建器:插件 Config → ResolvedPiAiProviderProfile 映射。
2
+ * profile 构建器(0.1.2-alpha.1 适配版):插件 Config → 官方 schema 形状的
3
+ * 「前置数据源归一化」→ 官方 resolveProfiles(或插件等价兜底)→
4
+ * ResolvedPiAiProviderProfile。
3
5
  *
4
- * 语义与官方 dsh-llm-pi-ai 的 resolveProfiles 链逐点对齐(index.js:1068-1471),
5
- * 差异仅在两点扩展:
6
- * - base 来源从"route 同名内置目录"换为 extends 三级数据源(inherit.ts);
7
- * - compat 2 字段枚举扩展为逐协议全量字段(compat.ts),写入即校验。
6
+ * 适配决策(resolveProfiles 复用评估,2026-08):
7
+ * - 官方 resolveProfiles 仅在 @deepseek-ai/dsh-llm-pi-ai 的 src/config.ts 子路径
8
+ * 导出,npm 发布形态不携带 src/(lib/index.js bundle),包根也不导出该
9
+ * 符号——真实部署(npm 布局 dsh 树)与 vendored 兜底都无法引用官方解析链;
10
+ * - 因此:dsh 树 dev 布局(src 存在)时经 src 子路径复用官方 resolveProfiles
11
+ * (resolve-dsh.ts 探测),其余形态走本文件的 resolveProfilesFallback——
12
+ * 语义逐行对齐官方 config.ts:resolveProfiles + catalog.ts:resolveRouteModels
13
+ * (协议限定本插件三协议),compat 门控以官方 catalog.ts COMPAT_GATES 为
14
+ * 唯一事实源(见 compat.ts);
15
+ * - 两条路径的输入相同:插件独占的 extends/models.dev/草稿路由等语义在此
16
+ * 「前置归一化」为全显式官方 schema 形状(模型条目字段全部落定),官方链
17
+ * 不再需要目录查找;normalizeRoute 的产物经 kit.resolveProfiles 进入
18
+ * 官方 adapter 消息链路。
8
19
  *
9
- * 产物经 `new PiAiAdapter({ profiles })` 进入官方消息链路;
10
- * 任何不可服务的配置在此处抛错(命名 route/model),配合 settings 写入校验,
11
- * 非法配置在写入时被拒绝、运行期保留上一份好配置。
20
+ * 与官方链的既有缝隙(记录在案):
21
+ * - 模型级 maxTokens 在官方 schema 中兼作「能力值」与「显式配置的请求默认
22
+ * cap」;归一化把继承值物化进条目后官方链会全部计入 configuredMaxTokens,
23
+ * 此处按插件原语义回填为「仅用户显式配置」——请求默认 cap 行为不变;
24
+ * - 官方 schema 无模型级 baseUrl/headers/cost:端点收敛为 route 级单一值
25
+ * (模型间端点不一致写时拒绝),headers/cost 不继承(0.84.2 目录无模型级
26
+ * headers;harness 不消费 cost 元数据,与官方 NO_COST 同口径)。
12
27
  * @module llm-pi/profiles
13
28
  */
14
29
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
15
- import type { ResolvedPiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
30
+ import type {
31
+ PiAiModelProfile,
32
+ PiAiProviderProfile,
33
+ ResolvedPiAiProviderProfile,
34
+ } from '@deepseek-ai/dsh-llm-pi-ai'
16
35
 
17
36
  import {
18
37
  builtinProviderBaseUrl,
@@ -20,11 +39,13 @@ import {
20
39
  type ModelBase,
21
40
  } from './catalog/builtin.ts'
22
41
  import type { ModelsDevSource } from './catalog/models-dev.ts'
23
- import { mergeCompat, validateCompat } from './compat.ts'
42
+ import { compatDispositionOf, compatFieldsOf, mergeCompat, validateCompat } from './compat.ts'
24
43
  import {
25
44
  DEFAULT_CONTEXT_WINDOW,
26
45
  DEFAULT_MAX_REQUEST_IMAGE_BYTES,
27
46
  DEFAULT_MAX_TOKENS,
47
+ DEFAULT_REQUEST_IMAGE_MAX_BYTES,
48
+ DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
28
49
  DEFAULT_STREAM_IDLE_TIMEOUT_MS,
29
50
  MAX_TIMER_DELAY_MS,
30
51
  type ModelEntryConfig,
@@ -63,97 +84,98 @@ function declaredInput(
63
84
  return configured === undefined || configured.length === 0 ? undefined : [...configured]
64
85
  }
65
86
 
66
- /**
67
- * 单个模型的 reasoning 物化(逐行对齐官方 resolveModelReasoning):
68
- * 显式 dict → 全档位确定的 thinkingLevelMap(未声明档位置 null);
69
- * false → 非推理模型;缺省 → 保留继承源的 reasoning 能力。
70
- */
71
- function resolveModelReasoning(
87
+ /** 显式 reasoningEfforts 字典的合法性校验(官方 resolveModelReasoning 同款规则)。 */
88
+ function validateReasoningEfforts(
72
89
  provider: string,
73
- entry: ModelEntryConfig,
74
- base: ModelBase,
75
- ): { reasoning: boolean; thinkingLevelMap?: Record<string, string | null> } {
76
- const efforts = entry.reasoningEfforts
77
- if (efforts === undefined) {
78
- if (base.reasoning === undefined) return { reasoning: false }
79
- return {
80
- reasoning: base.reasoning,
81
- ...(base.thinkingLevelMap === undefined
82
- ? {}
83
- : {
84
- thinkingLevelMap: base.thinkingLevelMap as Record<string, string | null>,
85
- }),
86
- }
87
- }
88
- if (efforts === false) return { reasoning: false }
90
+ entryId: string,
91
+ efforts: Record<string, string | null>,
92
+ ): void {
89
93
  if (Object.keys(efforts).length === 0) {
90
- invalid(provider, `model "${entry.id}" 的 reasoningEfforts 为空:声明档位、置 false 或缺省继承`)
94
+ invalid(provider, `model "${entryId}" 的 reasoningEfforts 为空:声明档位、置 false 或缺省继承`)
91
95
  }
92
96
  for (const level of THINKING_LEVELS) {
93
- const wire = (efforts as Record<string, string | null | undefined>)[level]
97
+ const wire = efforts[level]
94
98
  if (wire === undefined) continue
95
99
  if (wire === null) {
96
100
  if (level !== 'off')
97
- invalid(provider, `model "${entry.id}" reasoningEfforts.${level} 需要线值;仅 off 可留空`)
101
+ invalid(provider, `model "${entryId}" reasoningEfforts.${level} 需要线值;仅 off 可留空`)
98
102
  } else if (wire.length === 0) {
99
- invalid(provider, `model "${entry.id}" reasoningEfforts.${level} 不能为空字符串`)
103
+ invalid(provider, `model "${entryId}" reasoningEfforts.${level} 不能为空字符串`)
100
104
  }
101
105
  }
102
- const declared = THINKING_LEVELS.filter(
103
- (level) => (efforts as Record<string, unknown>)[level] !== undefined,
104
- )
106
+ const declared = THINKING_LEVELS.filter((level) => efforts[level] !== undefined)
105
107
  if (!declared.some((level) => level !== 'off')) {
106
- invalid(provider, `model "${entry.id}" reasoningEfforts 只有 off;声明思考档位或置 false`)
108
+ invalid(provider, `model "${entryId}" reasoningEfforts 只有 off;声明思考档位或置 false`)
107
109
  }
108
- const map: Record<string, string | null> = {}
109
- for (const level of THINKING_LEVELS) {
110
- const wire = (efforts as Record<string, string | null | undefined>)[level]
111
- if (wire === undefined) map[level] = null
112
- else if (wire !== null) map[level] = wire
113
- }
114
- return { reasoning: true, thinkingLevelMap: map }
115
110
  }
116
111
 
117
- /** Harness 自管凭据的 api-key auth(对齐官方 harnessApiKeyAuth,index.js:1215)。 */
118
- function harnessApiKeyAuth(name: string) {
119
- return {
120
- name,
121
- resolve: ({ credential }: { credential?: { key?: string } }) =>
122
- Promise.resolve({
123
- auth: credential?.key === undefined ? {} : { apiKey: credential.key },
124
- source: name,
125
- }),
112
+ /**
113
+ * 继承源的 reasoning 能力 → 官方 reasoningEfforts 字典(全显式归一化的一环)。
114
+ * - base 无推理能力 → false(与官方"手写模型缺省不推理"同语义);
115
+ * - base 带 thinkingLevelMap → 逐档位翻译:线值照抄;null(不支持)不声明
116
+ * (dict 未声明档位置 null = 不支持,语义一致);缺省档位(off/minimal/
117
+ * low/medium/high)线值 = 档位名(pi-ai dispatch 的 `map?.[level] ?? level`),
118
+ * xhigh/max 缺省 = 不支持(pi-ai 对两档的缺省语义)——翻译后支持档位集合
119
+ * 与线值与内置目录逐档位一致;
120
+ * - base 只有 reasoning:true 无表(models.dev 保守源)→ 声明基档位为默认
121
+ * 线值、xhigh/max 不支持——与 pi-ai 无表默认语义一致。
122
+ */
123
+ function reasoningEffortsFromBase(
124
+ provider: string,
125
+ entry: ModelEntryConfig,
126
+ base: ModelBase,
127
+ ): false | Record<string, string | null> {
128
+ if (entry.reasoningEfforts !== undefined) {
129
+ if (entry.reasoningEfforts === false) return false
130
+ validateReasoningEfforts(provider, entry.id, entry.reasoningEfforts)
131
+ return entry.reasoningEfforts
126
132
  }
133
+ if (base.reasoning !== true) return false
134
+ const map = base.thinkingLevelMap
135
+ if (map === undefined) {
136
+ return { off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high' }
137
+ }
138
+ const dict: Record<string, string | null> = {}
139
+ for (const level of THINKING_LEVELS) {
140
+ const wire = map[level]
141
+ if (wire === undefined) {
142
+ if (level === 'xhigh' || level === 'max') continue // 未声明 = 不支持
143
+ dict[level] = level
144
+ } else if (wire !== null) {
145
+ dict[level] = wire
146
+ }
147
+ }
148
+ return dict
127
149
  }
128
150
 
129
- interface MaterializedModel {
130
- id: string
131
- name: string
151
+ /** 归一化物化的单模型条目(含解析期事实,供 route 级唯一性检查)。 */
152
+ interface MaterializedEntry {
153
+ entry: PiAiModelProfile
132
154
  api: string
133
- provider: string
134
- baseUrl: string
135
- reasoning: boolean
136
- thinkingLevelMap?: Record<string, string | null>
137
- input: ('text' | 'image')[]
138
- cost: typeof NO_COST
139
- contextWindow: number
140
- maxTokens: number
141
- headers?: Record<string, string>
142
- compat?: Record<string, unknown>
155
+ baseUrl: string | undefined
143
156
  }
144
157
 
145
- /** 物化单个模型:继承 base 在下,条目显式字段逐字段覆盖。lenient 下缺 api/baseURL 时跳过(返回 null)。 */
158
+ /**
159
+ * 物化单个模型为官方 schema 条目(全显式)。lenient 下缺 api/baseURL 时
160
+ * 返回 null(调用方跳过该模型)。
161
+ */
146
162
  function materializeModel(
147
163
  route: string,
148
164
  profile: ProviderProfileConfig,
149
165
  entry: ModelEntryConfig,
150
166
  base: ModelBase,
151
167
  routeApi: string | undefined,
152
- providerBaseUrl: string | undefined,
153
168
  defaultInput: ('text' | 'image')[],
154
169
  deps: BuildDeps,
155
- configuredMaxTokens: Map<string, number>,
156
- ): MaterializedModel | null {
170
+ ): MaterializedEntry | null {
171
+ const legacyImageDetail = (entry as ModelEntryConfig & { imageDetail?: unknown }).imageDetail
172
+ if (legacyImageDetail !== undefined) {
173
+ invalid(
174
+ route,
175
+ `model "${entry.id}" 的 imageDetail 已随 0.1.2-alpha.1 移除;` +
176
+ '请改用 imagePixelBudget/imageMaxBytes(仅 adapter: deepseek)并删除 imageDetail',
177
+ )
178
+ }
157
179
  const api = profile.api ?? base.api ?? routeApi
158
180
  if (api === undefined) {
159
181
  if (deps.lenient) {
@@ -164,7 +186,8 @@ function materializeModel(
164
186
  }
165
187
  invalid(route, `model "${entry.id}" 需要 api:继承源未提供,请在 route 上设置 api`)
166
188
  }
167
- const baseUrl = profile.baseURL ?? base.baseUrl ?? providerBaseUrl
189
+ const baseUrl =
190
+ profile.baseURL ?? base.baseUrl ?? builtinProviderBaseUrl(deps.kit, profile.extends ?? '')
168
191
  if (baseUrl === undefined) {
169
192
  if (deps.lenient) {
170
193
  deps.warn?.(
@@ -174,6 +197,7 @@ function materializeModel(
174
197
  }
175
198
  invalid(route, `model "${entry.id}" 需要 baseURL:继承源未提供,请在 route 上设置 baseURL`)
176
199
  }
200
+ const name = entry.name ?? base.name ?? entry.id
177
201
  const contextWindow =
178
202
  entry.contextWindow ??
179
203
  base.contextWindow ??
@@ -181,7 +205,8 @@ function materializeModel(
181
205
  DEFAULT_CONTEXT_WINDOW
182
206
  const maxTokens =
183
207
  entry.maxTokens ?? base.maxTokens ?? profile.defaultMaxTokens ?? DEFAULT_MAX_TOKENS
184
- if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens)
208
+ const input = declaredInput(entry.input) ?? base.input ?? [...defaultInput]
209
+ // compat 校验(官方门控表 + schema 值约束)→ 合并(继承值仅同协议继承)
185
210
  validateCompat(api, profile.compat as Record<string, unknown> | undefined, `provider "${route}"`)
186
211
  validateCompat(
187
212
  api,
@@ -189,40 +214,44 @@ function materializeModel(
189
214
  `provider "${route}" model "${entry.id}"`,
190
215
  )
191
216
  const compat = mergeCompat(
192
- base.api === api ? base.compat : undefined,
217
+ base.api === api ? (base.compat as Record<string, unknown> | undefined) : undefined,
193
218
  profile.compat as Record<string, unknown> | undefined,
194
219
  entry.compat as Record<string, unknown> | undefined,
195
220
  )
221
+ if (compat !== undefined) {
222
+ validateCompat(api, compat, `provider "${route}" model "${entry.id}"`)
223
+ }
196
224
  return {
197
- id: entry.id,
198
- name: entry.name ?? base.name ?? entry.id,
225
+ entry: {
226
+ id: entry.id,
227
+ ...(name === entry.id ? {} : { name }),
228
+ ...(contextWindow === DEFAULT_CONTEXT_WINDOW ? {} : { contextWindow }),
229
+ ...(maxTokens === DEFAULT_MAX_TOKENS ? {} : { maxTokens }),
230
+ ...(input.length === 1 && input[0] === 'text' ? {} : { input }),
231
+ reasoningEfforts: reasoningEffortsFromBase(route, entry, base),
232
+ ...(compat === undefined ? {} : { compat }),
233
+ },
199
234
  api,
200
- provider: route,
201
235
  baseUrl,
202
- ...resolveModelReasoning(route, entry, base),
203
- input: declaredInput(entry.input) ?? base.input ?? [...defaultInput],
204
- cost: base.cost ?? NO_COST,
205
- contextWindow,
206
- maxTokens,
207
- ...(base.headers === undefined ? {} : { headers: { ...base.headers } }),
208
- ...(compat === undefined ? {} : { compat }),
209
236
  }
210
237
  }
211
238
 
212
- /** 物化一个 route 的全部模型;返回模型列表与显式配置的请求 cap 表。
213
- * lenient route 无任何可服务模型时返回 null(调用方跳过该 route)。 */
239
+ /**
240
+ * 物化一个 route 的全部模型为官方条目;返回 route 级唯一 api/baseURL 与
241
+ * 用户显式配置的请求 cap 表。lenient 下 route 无任何可服务模型时返回 null。
242
+ */
214
243
  function materializeRouteModels(
215
244
  route: string,
216
245
  profile: ProviderProfileConfig,
217
246
  deps: BuildDeps,
218
247
  defaultInput: ('text' | 'image')[],
219
248
  ): {
220
- models: MaterializedModel[]
249
+ models: PiAiModelProfile[]
250
+ api: string
251
+ baseUrl: string
221
252
  configuredMaxTokens: Map<string, number>
222
253
  } | null {
223
254
  const configuredMaxTokens = new Map<string, number>()
224
- const providerBaseUrl =
225
- profile.extends === undefined ? undefined : builtinProviderBaseUrl(deps.kit, profile.extends)
226
255
  const entries: { id: string; entry?: ModelEntryConfig; base: ModelBase }[] = []
227
256
  if (profile.models !== undefined && profile.models.length > 0) {
228
257
  const seen = new Set<string>()
@@ -243,6 +272,8 @@ function materializeRouteModels(
243
272
  )
244
273
  base = {}
245
274
  }
275
+ // 仅用户显式配置的 maxTokens 是"部署选择"(请求默认 cap);继承值只作能力。
276
+ if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens)
246
277
  entries.push({ id: entry.id, entry, base })
247
278
  }
248
279
  } else if (profile.extends !== undefined) {
@@ -261,22 +292,12 @@ function materializeRouteModels(
261
292
  .filter((api): api is string => api !== undefined),
262
293
  )
263
294
  const routeApi = apis.size === 1 ? [...apis][0] : undefined
264
- const models = entries
295
+ const materialized = entries
265
296
  .map(({ id, entry, base }) =>
266
- materializeModel(
267
- route,
268
- profile,
269
- entry ?? { id },
270
- base,
271
- routeApi,
272
- providerBaseUrl,
273
- defaultInput,
274
- deps,
275
- configuredMaxTokens,
276
- ),
297
+ materializeModel(route, profile, entry ?? { id }, base, routeApi, defaultInput, deps),
277
298
  )
278
- .filter((model): model is MaterializedModel => model !== null)
279
- if (models.length === 0) {
299
+ .filter((model): model is MaterializedEntry => model !== null)
300
+ if (materialized.length === 0) {
280
301
  if (deps.lenient) {
281
302
  deps.warn?.(
282
303
  `llm-pi: provider "${route}" 当前没有可服务的模型(继承源漂移),已跳过该 route 的注册`,
@@ -285,14 +306,27 @@ function materializeRouteModels(
285
306
  }
286
307
  invalid(route, 'route 内没有可服务的模型')
287
308
  }
288
- const finalApis = new Set(models.map((m) => m.api))
289
- if (finalApis.size > 1) {
309
+ const apisFinal = new Set(materialized.map((m) => m.api))
310
+ if (apisFinal.size > 1) {
311
+ invalid(
312
+ route,
313
+ `route 内模型协议不一致(${[...apisFinal].join(', ')});一个 route 只能服务一种协议`,
314
+ )
315
+ }
316
+ const baseUrls = new Set(materialized.map((m) => m.baseUrl))
317
+ if (baseUrls.size > 1) {
290
318
  invalid(
291
319
  route,
292
- `route 内模型协议不一致(${[...finalApis].join(', ')});一个 route 只能服务一种协议`,
320
+ `route 内模型端点不一致(${[...baseUrls].join(', ')});` +
321
+ '官方 schema 无模型级端点,请以 route 级 baseURL 统一',
293
322
  )
294
323
  }
295
- return { models, configuredMaxTokens }
324
+ return {
325
+ models: materialized.map((m) => m.entry),
326
+ api: materialized[0]?.api as string,
327
+ baseUrl: materialized[0]?.baseUrl as string,
328
+ configuredMaxTokens,
329
+ }
296
330
  }
297
331
 
298
332
  /**
@@ -308,94 +342,328 @@ export function isDraftRoute(profile: ProviderProfileConfig): boolean {
308
342
  )
309
343
  }
310
344
 
345
+ interface NormalizedRoute {
346
+ profile: PiAiProviderProfile
347
+ /** 用户显式配置的每请求输出 cap(仅 raw entry.maxTokens 显式设置的条目)。 */
348
+ configuredMaxTokens: Map<string, number>
349
+ }
350
+
311
351
  /**
312
- * 校验并物化全部 route。任一 route 不可服务即整体抛错——
313
- * settings 写入校验与运行期 profiles 回调共用本函数,
314
- * 保证"写入时被拒"与"运行期不可能拿到坏配置"互为表里。
315
- * 草稿路由(isDraftRoute)跳过物化,不参与 adapter 注册。
352
+ * 前置数据源归一化:一个 route 官方 schema 形状的全显式 profile。
353
+ * 不可服务的 route(deepseek 路由 / 草稿 / lenient 下无模型)返回 undefined。
354
+ * 校验规则对齐官方 resolveProfiles(baseURL/displayName 非空、idle 超时边界、
355
+ * 图片限额正整数、defaultInput 非空)。
316
356
  */
317
- export function buildProfiles(
318
- providers: Record<string, ProviderProfileConfig> | undefined,
357
+ function normalizeRoute(
358
+ route: string,
359
+ profile: ProviderProfileConfig,
319
360
  deps: BuildDeps,
361
+ ): NormalizedRoute | undefined {
362
+ // adapter: deepseek 的 route 由 profiles-deepseek.ts 物化(官方 DeepSeekAdapter 链路)
363
+ if ((profile.adapter ?? 'pi') === 'deepseek') return undefined
364
+ if (isDraftRoute(profile)) return undefined // 草稿路由:占位待补,不进 adapter
365
+ if (route.length === 0) throw new Error('llm-pi: provider 名不能为空')
366
+ // 注意:不做"route 名与内置 provider 名重名"的静态校验——内置名 ≠ 已注册
367
+ // route(如官方 llm-pi-ai 配置清空后 anthropic 名可用)。真实冲突只在注册期
368
+ // 暴露(DUPLICATE_ADAPTER),由 service 层逐个 route 注册降级处理。
369
+ if (profile.baseURL !== undefined && profile.baseURL.length === 0) invalid(route, 'baseURL 为空')
370
+ if (profile.displayName !== undefined && profile.displayName.length === 0)
371
+ invalid(route, 'displayName 为空')
372
+ const streamIdleTimeoutMs = profile.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
373
+ if (
374
+ !Number.isFinite(streamIdleTimeoutMs) ||
375
+ streamIdleTimeoutMs <= 0 ||
376
+ streamIdleTimeoutMs > MAX_TIMER_DELAY_MS
377
+ ) {
378
+ invalid(route, `streamIdleTimeoutMs 必须是 (0, ${MAX_TIMER_DELAY_MS}] 内的有限数`)
379
+ }
380
+ const maxRequestImageBytes = profile.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES
381
+ if (!Number.isInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) {
382
+ invalid(route, 'maxRequestImageBytes 必须是正整数')
383
+ }
384
+ const requestImagePixelBudget =
385
+ profile.requestImagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
386
+ if (!Number.isSafeInteger(requestImagePixelBudget) || requestImagePixelBudget <= 0) {
387
+ invalid(route, 'requestImagePixelBudget 必须是正整数')
388
+ }
389
+ const requestImageMaxBytes = profile.requestImageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES
390
+ if (!Number.isSafeInteger(requestImageMaxBytes) || requestImageMaxBytes <= 0) {
391
+ invalid(route, 'requestImageMaxBytes 必须是正整数')
392
+ }
393
+ const defaultInput = [...(profile.defaultInput ?? ['text'])] as ('text' | 'image')[]
394
+ if (defaultInput.length === 0) invalid(route, 'defaultInput 至少要声明一种模态')
395
+ const materialized = materializeRouteModels(route, profile, deps, defaultInput)
396
+ if (materialized === null) return undefined // lenient 下 route 无模型可服务,跳过注册
397
+ const { api, baseUrl, models, configuredMaxTokens } = materialized
398
+ const factory = deps.kit.protocolFactories[api as keyof DshKit['protocolFactories']]
399
+ if (factory === undefined) {
400
+ invalid(
401
+ route,
402
+ `api ${JSON.stringify(api)} 本插件无法服务(支持:openai-completions/openai-responses/anthropic-messages)`,
403
+ )
404
+ }
405
+ const displayName = profile.displayName ?? route
406
+ return {
407
+ profile: {
408
+ ...(profile.apiKeyEnv === undefined ? {} : { apiKeyEnv: profile.apiKeyEnv }),
409
+ displayName,
410
+ api,
411
+ ...(baseUrl === undefined ? {} : { baseURL: baseUrl }),
412
+ models,
413
+ ...(profile.defaultContextWindow === undefined
414
+ ? {}
415
+ : { defaultContextWindow: profile.defaultContextWindow }),
416
+ ...(profile.defaultMaxTokens === undefined
417
+ ? {}
418
+ : { defaultMaxTokens: profile.defaultMaxTokens }),
419
+ defaultInput,
420
+ ...(profile.headers === undefined ? {} : { headers: profile.headers }),
421
+ ...(profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }),
422
+ ...(profile.thinkingBudgets === undefined
423
+ ? {}
424
+ : { thinkingBudgets: profile.thinkingBudgets }),
425
+ ...(profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }),
426
+ ...(profile.transport === undefined ? {} : { transport: profile.transport }),
427
+ ...(profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs }),
428
+ ...(profile.websocketConnectTimeoutMs === undefined
429
+ ? {}
430
+ : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs }),
431
+ ...(profile.streamIdleTimeoutMs === undefined
432
+ ? {}
433
+ : { streamIdleTimeoutMs: profile.streamIdleTimeoutMs }),
434
+ ...(profile.maxRequestImageBytes === undefined
435
+ ? {}
436
+ : { maxRequestImageBytes: profile.maxRequestImageBytes }),
437
+ ...(profile.requestImagePixelBudget === undefined
438
+ ? {}
439
+ : { requestImagePixelBudget: profile.requestImagePixelBudget }),
440
+ ...(profile.requestImageMaxBytes === undefined
441
+ ? {}
442
+ : { requestImageMaxBytes: profile.requestImageMaxBytes }),
443
+ ...(profile.retryPolicy === undefined ? {} : { retryPolicy: profile.retryPolicy }),
444
+ } as PiAiProviderProfile,
445
+ configuredMaxTokens,
446
+ }
447
+ }
448
+
449
+ /** Harness 自管凭据的 api-key auth(对齐官方 harnessApiKeyAuth)。 */
450
+ function harnessApiKeyAuth(name: string) {
451
+ return {
452
+ name,
453
+ resolve: ({ credential }: { credential?: { key?: string } }) =>
454
+ Promise.resolve({
455
+ auth: credential?.key === undefined ? {} : { apiKey: credential.key },
456
+ source: name,
457
+ }),
458
+ }
459
+ }
460
+
461
+ /** resolveProfilesFallback 的套件依赖窄面(与 kit 同源,见 resolve-dsh.ts)。 */
462
+ export interface ResolverDeps {
463
+ createProvider: DshKit['createProvider']
464
+ protocolFactories: DshKit['protocolFactories']
465
+ resolveRetryPolicy: DshKit['resolveRetryPolicy']
466
+ }
467
+
468
+ /** 官方 resolveModelReasoning 的插件侧镜像:dict → {reasoning, thinkingLevelMap}。 */
469
+ function resolveModelReasoning(entry: PiAiModelProfile): {
470
+ reasoning: boolean
471
+ thinkingLevelMap?: Record<string, string | null>
472
+ } {
473
+ const efforts = entry.reasoningEfforts
474
+ if (efforts === undefined || efforts === false) return { reasoning: false }
475
+ const map: Record<string, string | null> = {}
476
+ for (const level of THINKING_LEVELS) {
477
+ const wire = (efforts as Record<string, string | null | undefined>)[level]
478
+ if (wire === undefined) map[level] = null
479
+ else if (wire !== null) map[level] = wire
480
+ }
481
+ return { reasoning: true, thinkingLevelMap: map }
482
+ }
483
+
484
+ /** 官方 resolveModelCompat 的插件侧镜像:门控表过滤 + 模型级拒绝不适配字段。 */
485
+ function resolveModelCompat(
486
+ provider: string,
487
+ entry: PiAiModelProfile,
488
+ api: string,
489
+ ): { compat: Record<string, unknown> } | Record<string, never> {
490
+ const configured: Record<string, unknown> = {}
491
+ for (const [field, value] of Object.entries(entry.compat ?? {})) {
492
+ if (compatDispositionOf(api as never, field) !== 'offer') {
493
+ const offered = compatFieldsOf(api as never)
494
+ invalid(
495
+ provider,
496
+ `model "${entry.id}" 的 compat.${field} 不适用于协议 ${api};` +
497
+ `该协议可配置字段:${offered.length === 0 ? '无' : offered.join(', ')}`,
498
+ )
499
+ }
500
+ if (value === undefined || value === null) {
501
+ invalid(provider, `model "${entry.id}" 的 compat.${field} 未设置值;给出值或移除该键`)
502
+ }
503
+ configured[field] = value
504
+ }
505
+ if (Object.keys(configured).length === 0) return {}
506
+ return { compat: configured }
507
+ }
508
+
509
+ /**
510
+ * 官方 resolveProfiles 的插件等价实现(npm 形态无 src/config.ts 可导入时兜底)。
511
+ * 输入为 normalizeRoute 的全显式产物(api/baseURL/条目字段全部落定),语义与
512
+ * 官方 config.ts:resolveProfiles + catalog.ts:resolveRouteModels 逐行对齐:
513
+ * 默认值(streamIdleTimeoutMs/图片限额/容量兜底)、retryPolicy 解析、
514
+ * configuredMaxTokens(此处按全显式条目计入,buildProfiles 再回填为用户显式
515
+ * 集)、createProvider 构建 piProvider(harness api-key auth)。
516
+ */
517
+ export function resolveProfilesFallback(
518
+ providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
519
+ deps: ResolverDeps,
320
520
  ): Map<string, ResolvedPiAiProviderProfile> {
321
521
  const resolved = new Map<string, ResolvedPiAiProviderProfile>()
322
- for (const [route, profile] of Object.entries(providers ?? {})) {
323
- // adapter: deepseek route 由 profiles-deepseek.ts 物化(官方 DeepSeekAdapter 链路)
324
- if ((profile.adapter ?? 'pi') === 'deepseek') continue
325
- if (isDraftRoute(profile)) continue // 草稿路由:占位待补,不进 adapter
326
- if (route.length === 0) throw new Error('llm-pi: provider 名不能为空')
327
- // 注意:不做"route 名与内置 provider 名重名"的静态校验——内置名 ≠ 已注册
328
- // route(如官方 llm-pi-ai 配置清空后 anthropic 名可用)。真实冲突只在注册期
329
- // 暴露(DUPLICATE_ADAPTER),由 service 层逐个 route 注册降级处理。
330
- if (profile.baseURL !== undefined && profile.baseURL.length === 0)
331
- invalid(route, 'baseURL 为空')
332
- if (profile.displayName !== undefined && profile.displayName.length === 0)
333
- invalid(route, 'displayName 为空')
334
- const streamIdleTimeoutMs = profile.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
522
+ for (const [provider, source] of Object.entries(providers ?? {})) {
523
+ if (source.baseURL !== undefined && source.baseURL.length === 0) {
524
+ invalid(provider, 'baseURL 为空')
525
+ }
526
+ if (source.displayName !== undefined && source.displayName.length === 0) {
527
+ invalid(provider, 'displayName 为空')
528
+ }
529
+ const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
335
530
  if (
336
531
  !Number.isFinite(streamIdleTimeoutMs) ||
337
532
  streamIdleTimeoutMs <= 0 ||
338
533
  streamIdleTimeoutMs > MAX_TIMER_DELAY_MS
339
534
  ) {
340
- invalid(route, `streamIdleTimeoutMs 必须是 (0, ${MAX_TIMER_DELAY_MS}] 内的有限数`)
535
+ invalid(provider, `streamIdleTimeoutMs 必须是 (0, ${MAX_TIMER_DELAY_MS}] 内的有限数`)
536
+ }
537
+ const maxRequestImageBytes = source.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES
538
+ if (!Number.isInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) {
539
+ invalid(provider, 'maxRequestImageBytes 必须是正整数')
540
+ }
541
+ const requestImagePixelBudget =
542
+ source.requestImagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET
543
+ if (!Number.isSafeInteger(requestImagePixelBudget) || requestImagePixelBudget <= 0) {
544
+ invalid(provider, 'requestImagePixelBudget 必须是正整数')
545
+ }
546
+ const requestImageMaxBytes = source.requestImageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES
547
+ if (!Number.isSafeInteger(requestImageMaxBytes) || requestImageMaxBytes <= 0) {
548
+ invalid(provider, 'requestImageMaxBytes 必须是正整数')
341
549
  }
342
- const defaultInput = [...(profile.defaultInput ?? ['text'])] as ('text' | 'image')[]
343
- if (defaultInput.length === 0) invalid(route, 'defaultInput 至少要声明一种模态')
344
- const displayName = profile.displayName ?? route
345
- const catalog = materializeRouteModels(route, profile, deps, defaultInput)
346
- if (catalog === null) continue // lenient 下 route 无模型可服务,跳过注册
347
- const api = catalog.models[0]?.api
348
- const factory =
349
- api === undefined
350
- ? undefined
351
- : deps.kit.protocolFactories[api as keyof DshKit['protocolFactories']]
550
+ const defaultInput = [...(source.defaultInput ?? ['text'])]
551
+ if (defaultInput.length === 0) invalid(provider, 'defaultInput 至少要声明一种模态')
552
+ const displayName = source.displayName ?? provider
553
+ const api = source.api
554
+ if (api === undefined) {
555
+ invalid(
556
+ provider,
557
+ '需要 api:本插件仅支持 openai-completions/openai-responses/anthropic-messages',
558
+ )
559
+ }
560
+ const factory = deps.protocolFactories[api as keyof DshKit['protocolFactories']]
352
561
  if (factory === undefined) {
353
562
  invalid(
354
- route,
563
+ provider,
355
564
  `api ${JSON.stringify(api)} 本插件无法服务(支持:openai-completions/openai-responses/anthropic-messages)`,
356
565
  )
357
566
  }
358
- const piProvider = deps.kit.createProvider({
359
- id: route,
360
- name: displayName,
361
- ...(profile.baseURL === undefined
362
- ? profile.extends === undefined
363
- ? {}
364
- : { baseUrl: builtinProviderBaseUrl(deps.kit, profile.extends) }
365
- : { baseUrl: profile.baseURL }),
366
- ...(profile.headers === undefined ? {} : { headers: { ...profile.headers } }),
367
- auth: { apiKey: harnessApiKeyAuth(displayName) },
368
- models: catalog.models,
369
- api: factory() as never,
567
+ if (source.baseURL === undefined) {
568
+ invalid(provider, '需要 baseURL:官方 schema 无模型级端点,端点只能 route 级表达')
569
+ }
570
+ // 模型物化:全显式条目 + route 级 api/baseURL(官方 resolveRouteModels 镜像,
571
+ // 无目录继承——归一化已把继承值落定在条目里)
572
+ const configuredMaxTokens = new Map<string, number>()
573
+ const seen = new Set<string>()
574
+ const models = (source.models ?? []).map((entry) => {
575
+ if (entry.id.length === 0) invalid(provider, '存在空 id 的模型条目')
576
+ if (seen.has(entry.id)) invalid(provider, `模型 "${entry.id}" 重复列出`)
577
+ seen.add(entry.id)
578
+ const contextWindow =
579
+ entry.contextWindow ?? source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW
580
+ if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
581
+ invalid(provider, `model "${entry.id}" contextWindow 必须是正整数`)
582
+ }
583
+ const maxTokens = entry.maxTokens ?? source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS
584
+ if (!Number.isInteger(maxTokens) || maxTokens <= 0) {
585
+ invalid(provider, `model "${entry.id}" maxTokens 必须是正整数`)
586
+ }
587
+ if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens)
588
+ return {
589
+ id: entry.id,
590
+ name: entry.name ?? entry.id,
591
+ api,
592
+ provider,
593
+ baseUrl: source.baseURL as string,
594
+ input: [...(entry.input ?? defaultInput)],
595
+ cost: NO_COST,
596
+ contextWindow,
597
+ maxTokens,
598
+ ...resolveModelReasoning(entry),
599
+ ...resolveModelCompat(provider, entry, api),
600
+ }
370
601
  })
371
- resolved.set(route, {
372
- provider: route,
602
+ if (models.length === 0) invalid(provider, 'route 内没有可服务的模型')
603
+ const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
604
+ resolved.set(provider, {
605
+ ...rest,
606
+ provider,
373
607
  displayName,
374
- ...(profile.apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(profile.apiKeyEnv) }),
608
+ ...(apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }),
375
609
  streamIdleTimeoutMs,
376
- maxRequestImageBytes: profile.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES,
377
- retryPolicy: deps.kit.resolveRetryPolicy(
378
- profile.retryPolicy as Parameters<DshKit['resolveRetryPolicy']>[0],
379
- `llm-pi: provider "${route}" retryPolicy`,
610
+ maxRequestImageBytes,
611
+ requestImagePixelBudget,
612
+ requestImageMaxBytes,
613
+ retryPolicy: deps.resolveRetryPolicy(
614
+ retryPolicy,
615
+ `llm-pi: provider "${provider}" retryPolicy`,
380
616
  ),
381
- ...(profile.headers === undefined ? {} : { headers: { ...profile.headers } }),
382
- ...(profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }),
383
- ...(profile.thinkingBudgets === undefined
384
- ? {}
385
- : { thinkingBudgets: { ...profile.thinkingBudgets } }),
386
- ...(profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }),
387
- ...(profile.transport === undefined ? {} : { transport: profile.transport }),
388
- ...(profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs }),
389
- ...(profile.websocketConnectTimeoutMs === undefined
617
+ ...(rest.headers === undefined ? {} : { headers: { ...rest.headers } }),
618
+ ...(rest.thinkingBudgets === undefined
390
619
  ? {}
391
- : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs }),
392
- configuredMaxTokens: catalog.configuredMaxTokens,
393
- piProvider,
620
+ : { thinkingBudgets: { ...rest.thinkingBudgets } }),
621
+ configuredMaxTokens,
622
+ piProvider: deps.createProvider({
623
+ id: provider,
624
+ name: displayName,
625
+ ...(source.baseURL === undefined ? {} : { baseUrl: source.baseURL }),
626
+ auth: { apiKey: harnessApiKeyAuth(displayName) },
627
+ models,
628
+ api: factory() as never,
629
+ }),
394
630
  } as ResolvedPiAiProviderProfile)
395
631
  }
396
632
  return resolved
397
633
  }
398
634
 
635
+ /**
636
+ * 校验并物化全部 pi route。任一 route 不可服务即整体抛错——
637
+ * settings 写入校验与运行期 profiles 回调共用本函数,
638
+ * 保证"写入时被拒"与"运行期不可能拿到坏配置"互为表里。
639
+ * 草稿路由(isDraftRoute)跳过物化,不参与 adapter 注册。
640
+ */
641
+ export function buildProfiles(
642
+ providers: Record<string, ProviderProfileConfig> | undefined,
643
+ deps: BuildDeps,
644
+ ): Map<string, ResolvedPiAiProviderProfile> {
645
+ const resolved = new Map<string, ResolvedPiAiProviderProfile>()
646
+ for (const [route, profile] of Object.entries(providers ?? {})) {
647
+ let normalized: NormalizedRoute | undefined
648
+ try {
649
+ normalized = normalizeRoute(route, profile, deps)
650
+ if (normalized === undefined) continue
651
+ const single = deps.kit.resolveProfiles({ [route]: normalized.profile })
652
+ for (const [name, built] of single) {
653
+ // configuredMaxTokens 回填为用户显式集(官方链把物化继承值也计入请求默认 cap,
654
+ // 与插件原语义不符;见文件头"既有缝隙")。
655
+ resolved.set(name, { ...built, configuredMaxTokens: normalized.configuredMaxTokens })
656
+ }
657
+ } catch (error) {
658
+ if (!deps.lenient) throw error
659
+ deps.warn?.(
660
+ `llm-pi: provider "${route}" 不可服务(${error instanceof Error ? error.message : String(error)}),已跳过注册`,
661
+ )
662
+ }
663
+ }
664
+ return resolved
665
+ }
666
+
399
667
  /** settings 写入校验钩子:完整试跑解析,非法配置在写入处拒绝。 */
400
668
  export function assertServiceable(
401
669
  config: { providers?: Record<string, ProviderProfileConfig> },