@dsh-plus/llm-pi 0.1.42 → 0.1.44

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,156 @@
1
+ /**
2
+ * 运行期套件元信息:插件实际在用哪一份 pi-ai / 官方适配器,以及版本是否落在
3
+ * 本插件验证过的区间内。
4
+ *
5
+ * 存在的理由:插件**按文件路径**动态加载已装 dsh 树里的 pi-ai(版本随 dsh 升级
6
+ * 自动变化,见 resolve-dsh.ts),因此"当前生效版本"不是 manifest 上写的版本。
7
+ * 配置页把它显示出来,版本漂移一眼可见;`VERIFIED_PI_AI_RANGE` 只用于**提示**,
8
+ * 永不阻断——能不能用由运行期形状自检(assertKitShape)与逐项降级决定。
9
+ * @module llm-pi/kit-meta
10
+ */
11
+
12
+ import { readFileSync } from 'node:fs'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ /** 实际生效的三个版本;拿不到即省略(读文件失败不视为错误)。 */
17
+ export interface KitVersions {
18
+ /** pi-ai(目录与协议实现的来源)。 */
19
+ piAi?: string
20
+ /** 官方 dsh-llm-pi-ai(PiAiAdapter 与 compat 门控的来源)。 */
21
+ piAiAdapter?: string
22
+ /** dsh 本体(安装树根 package.json,仅在根就是 dsh 包时可得)。 */
23
+ dsh?: string
24
+ }
25
+
26
+ /** 本插件开发/测试覆盖过的 pi-ai 区间(semver range,仅用于状态行提示)。 */
27
+ export const VERIFIED_PI_AI_RANGE = '>=0.85.1 <0.88.0'
28
+
29
+ /** 读 package.json 的 version;文件不存在/非 JSON/无 version 都返回 undefined。 */
30
+ export function readPackageVersion(manifestPath: string): string | undefined {
31
+ try {
32
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
33
+ const version = (parsed as { version?: unknown } | null)?.version
34
+ return typeof version === 'string' && version.length > 0 ? version : undefined
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
39
+
40
+ /** 读包目录的版本(`<dir>/package.json`)。 */
41
+ export function packageVersionOf(packageDir: string): string | undefined {
42
+ return readPackageVersion(join(packageDir, 'package.json'))
43
+ }
44
+
45
+ /** 只读包名(判断安装树根是不是 dsh 本体)。 */
46
+ function readPackageName(manifestPath: string): string | undefined {
47
+ try {
48
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
49
+ const name = (parsed as { name?: unknown } | null)?.name
50
+ return typeof name === 'string' ? name : undefined
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ /** 收集 dsh 安装树内实际加载的那几个包的版本。 */
57
+ export function collectTreeVersions(root: string): KitVersions {
58
+ const nm = join(root, 'node_modules')
59
+ const piAi = packageVersionOf(join(nm, '@earendil-works', 'pi-ai'))
60
+ const adapter = packageVersionOf(join(nm, '@deepseek-ai', 'dsh-llm-pi-ai'))
61
+ // 树根 package.json 是 dsh 本体时才算 dsh 版本(源码仓库布局下根可能是别的包)。
62
+ const rootManifest = join(root, 'package.json')
63
+ const dsh =
64
+ readPackageName(rootManifest) === '@deepseek-ai/dsh' ? packageVersionOf(root) : undefined
65
+ return {
66
+ ...(piAi === undefined ? {} : { piAi }),
67
+ ...(adapter === undefined ? {} : { piAiAdapter: adapter }),
68
+ ...(dsh === undefined ? {} : { dsh }),
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 由模块入口文件向上找包目录(最多 3 层,兼容 `<pkg>/{lib,dist}/index.js`
74
+ * 与根目录入口两种布局);找不到返回 undefined。
75
+ */
76
+ function packageDirOf(entryFile: string): string | undefined {
77
+ let dir = dirname(entryFile)
78
+ for (let depth = 0; depth < 3; depth += 1) {
79
+ if (readPackageName(join(dir, 'package.json')) !== undefined) return dir
80
+ const parent = dirname(dir)
81
+ if (parent === dir) return undefined
82
+ dir = parent
83
+ }
84
+ return undefined
85
+ }
86
+
87
+ /**
88
+ * 解析已安装包的目录(本进程内解析,只用于读 package.json)。
89
+ * 拿不到即返回 undefined(该版本号留空,不影响运行)。
90
+ */
91
+ export function installedPackageDir(specifier: string): string | undefined {
92
+ try {
93
+ return packageDirOf(fileURLToPath(import.meta.resolve(specifier)))
94
+ } catch {
95
+ return undefined
96
+ }
97
+ }
98
+
99
+ /** 收集插件 vendored 兜底副本的版本(回退路径下"生效版本"即这两份)。 */
100
+ export function collectVendoredVersions(): KitVersions {
101
+ const piAiDir = installedPackageDir('@earendil-works/pi-ai')
102
+ const adapterDir = installedPackageDir('@deepseek-ai/dsh-llm-pi-ai')
103
+ const piAi = piAiDir === undefined ? undefined : packageVersionOf(piAiDir)
104
+ const adapter = adapterDir === undefined ? undefined : packageVersionOf(adapterDir)
105
+ return {
106
+ ...(piAi === undefined ? {} : { piAi }),
107
+ ...(adapter === undefined ? {} : { piAiAdapter: adapter }),
108
+ }
109
+ }
110
+
111
+ /** 已验证区间的解析端点(与 {@link VERIFIED_PI_AI_RANGE} 同步维护)。 */
112
+ const VERIFIED_MIN: readonly [number, number, number] = [0, 85, 1]
113
+ const VERIFIED_MAX_EXCLUSIVE: readonly [number, number, number] = [0, 88, 0]
114
+
115
+ /** 解析 `x.y.z[-pre][+build]` 的数值三元组;形状不符返回 undefined。 */
116
+ function numericTriple(version: string): [number, number, number] | undefined {
117
+ const core = version.split(/[-+]/, 1)[0] ?? ''
118
+ const parts = core.split('.')
119
+ if (parts.length !== 3) return undefined
120
+ const nums = parts.map((part) => (/^\d+$/.test(part) ? Number.parseInt(part, 10) : Number.NaN))
121
+ const [a, b, c] = nums
122
+ if (a === undefined || b === undefined || c === undefined) return undefined
123
+ if (!Number.isInteger(a) || !Number.isInteger(b) || !Number.isInteger(c)) return undefined
124
+ return [a, b, c]
125
+ }
126
+
127
+ function compareTriple(
128
+ left: readonly [number, number, number],
129
+ right: readonly [number, number, number],
130
+ ): number {
131
+ for (let i = 0; i < 3; i += 1) {
132
+ const diff = (left[i] as number) - (right[i] as number)
133
+ if (diff !== 0) return diff
134
+ }
135
+ return 0
136
+ }
137
+
138
+ /**
139
+ * 生效 pi-ai 版本不在已验证区间时的提示文案(**提示而非阻断**)。
140
+ * 区间内或版本号形状无法解析时返回 undefined。
141
+ */
142
+ export function piAiVersionNotice(version: string | undefined): string | undefined {
143
+ if (version === undefined) return undefined
144
+ const parsed = numericTriple(version)
145
+ if (parsed === undefined) return undefined
146
+ if (compareTriple(parsed, VERIFIED_MIN) < 0) {
147
+ return `当前生效 pi-ai ${version} 低于本插件验证过的下限 ${VERIFIED_PI_AI_RANGE}`
148
+ }
149
+ if (compareTriple(parsed, VERIFIED_MAX_EXCLUSIVE) >= 0) {
150
+ return (
151
+ `当前生效 pi-ai ${version} 超出本插件验证过的区间 ${VERIFIED_PI_AI_RANGE};` +
152
+ '已启用现场推导与形状自检,若出现异常请先看诊断行'
153
+ )
154
+ }
155
+ return undefined
156
+ }
@@ -1,24 +1,25 @@
1
1
  /**
2
- * compat 门控表的**自动继承**:从官方 `dsh-llm-pi-ai` 安装副本现场推导,
3
- * 取代早期的手抄镜像表(手抄遗漏即静默误拒官方可配字段,见 ADR/文档的
4
- * 0.1.5-rc.1 教训)。
2
+ * 官方适配面的**自动继承**:从已安装的官方副本(`dsh-llm-pi-ai` + `pi-ai`)
3
+ * 现场推导三样东西,取代早期的手抄镜像:
5
4
  *
6
- * 官方不导出该表(包根仅 7 个符号、`src/` 不随 npm 发布),但有两条稳定可用的
7
- * 机器可读来源,本模块同时消费:
5
+ * 1. **线协议集合**:官方包根导出的 `supportedProtocols()`(官方 PROTOCOLS 表
6
+ * 的唯一投影)。官方新增协议时本插件自动可用,不再需要改代码;
7
+ * 2. **compat 门控与取值约束**:门控取自运行期 bundle 的 `COMPAT_GATES`
8
+ * (事实源:运行期拒绝哪些字段由它决定),取值约束取自导出的 Config schema
9
+ * 的 `providers.*.compat` 节点(boolean / number.step(1) / enum / 对象);
10
+ * 3. **模型条目字段集**:Config schema 的 `providers.*.models` 键集——继承时按此
11
+ * 透传 pi-ai 目录的同名字段,官方将来新增模型级字段即自动跟随。
8
12
  *
9
- * 1. **运行期 bundle**(`lib/index.js`,即真正执行门控的那份代码):
10
- * `const COMPAT_GATES = { "openai-completions": COMPLETIONS_COMPAT_GATE, ... }`
11
- * ——给出「协议 → 门控」映射(anthropic 在 bundle 内联,解析器同时支持
12
- * 命名常量与内联对象两种形态)。门控表取自这里而非 d.ts,因为它是**事实源**:
13
- * 运行期拒绝哪些字段由它决定。
14
- * 2. **导出的 Config schema**(schemastery 标准 schema):`providers.*.compat`
15
- * 节点逐字段给出取值约束(boolean/number.step(1)/enum/对象),据此推导
16
- * 取值校验,不再手写 VALUE_SPECS。
13
+ * 历史教训(0.1.5-rc.1):compat 门控曾是官方 catalog.ts 的手抄镜像,官方扩容
14
+ * offer 字段而旧表漏收,导致官方可配字段被本插件**误拒**(静默功能缺失、无报错)。
15
+ * 现改为现场推导,`tests/official-surface.test.ts` 直读官方副本守门推导正确性。
17
16
  *
18
- * 推导失败(官方改布局/改打包形态)时回退到 FALLBACK_TABLE(最后已知快照,
19
- * 随本文件版本冻结)并给出诊断——绝不因推导失败弄挂插件启动。
20
- * @module llm-pi/compat-gates
17
+ * 推导失败(官方改布局/改打包形态)时回退到 FALLBACK_*:compat 表用最后已知快照
18
+ * 且**放宽未知键**(见 compat.ts:宁可放行交给官方自身校验,也不误拒官方新字段),
19
+ * 协议回退三元组、模型字段回退已知七键,各自给出诊断——绝不因推导失败弄挂启动。
20
+ * @module llm-pi/official-surface
21
21
  */
22
+ import { FALLBACK_PROTOCOLS } from './config.ts'
22
23
 
23
24
  /** 字段可配性:offer = 官方允许写;withhold = 官方为厂商内置、写时拒绝。 */
24
25
  export type CompatDisposition = 'offer' | 'withhold'
@@ -161,6 +162,49 @@ export function deriveSpecs(module: Record<string, unknown>): Record<string, Com
161
162
  return out
162
163
  }
163
164
 
165
+ /**
166
+ * 从官方适配器模块推导线协议集合:官方包根导出的 `supportedProtocols()`
167
+ * (`Object.keys(PROTOCOLS)` 的投影,顺序即官方表顺序)。
168
+ * 非函数/返回空/含非字符串项都视为推导失败,调用方回退内置三元组。
169
+ */
170
+ export function deriveProtocols(module: Record<string, unknown>): string[] | undefined {
171
+ const reader = module['supportedProtocols']
172
+ if (typeof reader !== 'function') return undefined
173
+ const value = (reader as () => unknown)()
174
+ if (!Array.isArray(value) || value.length === 0) return undefined
175
+ const ids = value.filter((id): id is string => typeof id === 'string' && id.length > 0)
176
+ return ids.length === value.length ? ids : undefined
177
+ }
178
+
179
+ /**
180
+ * 从官方 Config schema 推导模型条目字段集(`providers.*.models` 的元素键集)。
181
+ * 这些是官方适配器**接受**的模型级键;继承时按此透传 pi-ai 目录的同名字段。
182
+ */
183
+ export function deriveModelEntryFields(module: Record<string, unknown>): string[] | undefined {
184
+ const config = module['Config'] as SchemaNode | undefined
185
+ const models = config?.dict?.['providers']?.inner?.dict?.['models']
186
+ const fields = models?.inner?.dict
187
+ if (fields === undefined) return undefined
188
+ const keys = Object.keys(fields)
189
+ return keys.length > 0 ? keys : undefined
190
+ }
191
+
192
+ /** 推导失败时的模型条目字段兜底(0.2.x 线实测键集)。 */
193
+ export const FALLBACK_MODEL_ENTRY_FIELDS = [
194
+ 'id',
195
+ 'name',
196
+ 'contextWindow',
197
+ 'maxTokens',
198
+ 'input',
199
+ 'reasoningEfforts',
200
+ 'compat',
201
+ ] as const
202
+
203
+ /** 协议推导失败时的兜底三元组(转出为可变数组,便于调用方直接持有)。 */
204
+ export function fallbackProtocols(): string[] {
205
+ return [...FALLBACK_PROTOCOLS]
206
+ }
207
+
164
208
  /** 官方门控表不可解析时的最后已知快照(0.1.5-rc.2 实测值)。 */
165
209
  export const FALLBACK_TABLE: CompatTable = {
166
210
  source: 'fallback',
package/src/profiles.ts CHANGED
@@ -35,10 +35,10 @@ import type {
35
35
 
36
36
  import {
37
37
  builtinProviderBaseUrl,
38
+ hasBuiltinProvider,
38
39
  inheritedCatalogEntries,
39
40
  type ModelBase,
40
41
  } from './catalog/builtin.ts'
41
- import type { ModelsDevSource } from './catalog/models-dev.ts'
42
42
  import { mergeCompat, validateCompat } from './compat.ts'
43
43
  import {
44
44
  DEFAULT_CONTEXT_WINDOW,
@@ -61,9 +61,8 @@ const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
61
61
 
62
62
  export interface BuildDeps {
63
63
  kit: DshKit
64
- modelsDev?: ModelsDevSource
65
64
  /**
66
- * 运行期宽松模式:数据源漂移(models.dev 刷新/内置目录变化)导致已写入的
65
+ * 运行期宽松模式:数据源漂移(dsh 树升级换 pi-ai 版本导致内置目录变化)使已写入的
67
66
  * extends 引用失效时,降级/跳过并告警,而不是抛错把整个 route 弄挂。
68
67
  * 写时校验(assertServiceable)保持严格(缺省),非法引用在写入处拒绝。
69
68
  */
@@ -84,6 +83,29 @@ function declaredInput(
84
83
  return configured === undefined || configured.length === 0 ? undefined : [...configured]
85
84
  }
86
85
 
86
+ /**
87
+ * 归一化条目里"本层不处理、需原样进 pi-ai Model"的字段(继承自目录的未来
88
+ * 模型级字段,如 samplingParams)。官方 schema 当前的模型条目键(id/name/
89
+ * contextWindow/maxTokens/input/reasoningEfforts/compat)在此全部排除:
90
+ * 它们要么由本层显式翻译,要么不能出现在 pi-ai Model 上。
91
+ */
92
+ function extraEntryFields(entry: PiAiModelProfile): Record<string, unknown> {
93
+ const known = new Set([
94
+ 'id',
95
+ 'name',
96
+ 'contextWindow',
97
+ 'maxTokens',
98
+ 'input',
99
+ 'reasoningEfforts',
100
+ 'compat',
101
+ ])
102
+ const out: Record<string, unknown> = {}
103
+ for (const [key, value] of Object.entries(entry)) {
104
+ if (!known.has(key) && value !== undefined) out[key] = value
105
+ }
106
+ return out
107
+ }
108
+
87
109
  /** 显式 reasoningEfforts 字典的合法性校验(官方 resolveModelReasoning 同款规则)。 */
88
110
  function validateReasoningEfforts(
89
111
  provider: string,
@@ -223,6 +245,9 @@ function materializeModel(
223
245
  // 用户手写层(上面两次 validateCompat)。
224
246
  return {
225
247
  entry: {
248
+ // 继承源的其余官方可继承字段先落定(官方 schema 接受的键集运行期推导),
249
+ // 表单显式字段随后覆盖:官方将来新增模型级字段即自动跟随,无需改本插件。
250
+ ...(base.extra ?? {}),
226
251
  id: entry.id,
227
252
  ...(name === entry.id ? {} : { name }),
228
253
  ...(contextWindow === DEFAULT_CONTEXT_WINDOW ? {} : { contextWindow }),
@@ -230,7 +255,7 @@ function materializeModel(
230
255
  ...(input.length === 1 && input[0] === 'text' ? {} : { input }),
231
256
  reasoningEfforts: reasoningEffortsFromBase(route, entry, base),
232
257
  ...(compat === undefined ? {} : { compat }),
233
- },
258
+ } as PiAiModelProfile,
234
259
  api,
235
260
  baseUrl,
236
261
  }
@@ -261,7 +286,7 @@ function materializeRouteModels(
261
286
  seen.add(entry.id)
262
287
  let base: ModelBase
263
288
  try {
264
- base = resolveModelBase(route, profile, entry, deps.kit, deps.modelsDev).base
289
+ base = resolveModelBase(route, profile, entry, deps.kit).base
265
290
  } catch (error) {
266
291
  // 运行期数据源漂移:已写入的引用在当前目录 miss。严格模式(写时校验)保持拒绝;
267
292
  // lenient 模式降级为手写条目并告警,避免整个 route 不可服务。
@@ -366,6 +391,20 @@ function normalizeRoute(
366
391
  // 注意:不做"route 名与内置 provider 名重名"的静态校验——内置名 ≠ 已注册
367
392
  // route(如官方 llm-pi-ai 配置清空后 anthropic 名可用)。真实冲突只在注册期
368
393
  // 暴露(DUPLICATE_ADAPTER),由 service 层逐个 route 注册降级处理。
394
+ if (profile.extends !== undefined && !hasBuiltinProvider(deps.kit, profile.extends)) {
395
+ // 继承源只有 pi-ai 内置目录(models.dev 兜底已移除):写错 provider 名过去会
396
+ // 静默退化成手写条目,现在写入即拒绝并给出可用集合,避免"配了没生效"。
397
+ if (!deps.lenient) {
398
+ invalid(
399
+ route,
400
+ `extends ${JSON.stringify(profile.extends)} 不是 pi-ai ${deps.kit.versions.piAi ?? '?'} 的内置 provider;` +
401
+ `可用:${deps.kit.getBuiltinProviders().join(', ')}`,
402
+ )
403
+ }
404
+ deps.warn?.(
405
+ `llm-pi: provider "${route}" 的 extends ${JSON.stringify(profile.extends)} 不是当前 pi-ai 的内置 provider,已忽略该继承源`,
406
+ )
407
+ }
369
408
  if (profile.baseURL !== undefined && profile.baseURL.length === 0) invalid(route, 'baseURL 为空')
370
409
  if (profile.displayName !== undefined && profile.displayName.length === 0)
371
410
  invalid(route, 'displayName 为空')
@@ -395,11 +434,11 @@ function normalizeRoute(
395
434
  const materialized = materializeRouteModels(route, profile, deps, defaultInput)
396
435
  if (materialized === null) return undefined // lenient 下 route 无模型可服务,跳过注册
397
436
  const { api, baseUrl, models, configuredMaxTokens } = materialized
398
- const factory = deps.kit.protocolFactories[api as keyof DshKit['protocolFactories']]
437
+ const factory = deps.kit.protocolFactories[api]
399
438
  if (factory === undefined) {
400
439
  invalid(
401
440
  route,
402
- `api ${JSON.stringify(api)} 本插件无法服务(支持:openai-completions/openai-responses/anthropic-messages)`,
441
+ `api ${JSON.stringify(api)} 本插件无法服务(当前生效 pi-ai ${deps.kit.versions.piAi ?? '?'} 支持的协议:${deps.kit.protocols.join('/')})`,
403
442
  )
404
443
  }
405
444
  const displayName = profile.displayName ?? route
@@ -462,6 +501,8 @@ function harnessApiKeyAuth(name: string) {
462
501
  export interface ResolverDeps {
463
502
  createProvider: DshKit['createProvider']
464
503
  protocolFactories: DshKit['protocolFactories']
504
+ /** 当前生效协议集合(写时错误消息用;与 protocolFactories 的键同源)。 */
505
+ protocols: string[]
465
506
  resolveRetryPolicy: DshKit['resolveRetryPolicy']
466
507
  }
467
508
 
@@ -550,16 +591,13 @@ export function resolveProfilesFallback(
550
591
  const displayName = source.displayName ?? provider
551
592
  const api = source.api
552
593
  if (api === undefined) {
553
- invalid(
554
- provider,
555
- '需要 api:本插件仅支持 openai-completions/openai-responses/anthropic-messages',
556
- )
594
+ invalid(provider, `需要 api:本插件只服务官方适配器声明的协议(${deps.protocols.join('/')})`)
557
595
  }
558
- const factory = deps.protocolFactories[api as keyof DshKit['protocolFactories']]
596
+ const factory = deps.protocolFactories[api]
559
597
  if (factory === undefined) {
560
598
  invalid(
561
599
  provider,
562
- `api ${JSON.stringify(api)} 本插件无法服务(支持:openai-completions/openai-responses/anthropic-messages)`,
600
+ `api ${JSON.stringify(api)} 本插件无法服务(当前支持的协议:${deps.protocols.join('/')})`,
563
601
  )
564
602
  }
565
603
  if (source.baseURL === undefined) {
@@ -589,6 +627,7 @@ export function resolveProfilesFallback(
589
627
  }
590
628
  if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens)
591
629
  return {
630
+ ...extraEntryFields(entry),
592
631
  id: entry.id,
593
632
  name: entry.name ?? entry.id,
594
633
  api,