@dsh-plus/llm-pi 0.1.42 → 0.1.43

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dsh-plus/llm-pi",
3
- "version": "0.1.42",
4
- "description": "dsh-plus service+ui plugin: 基于 PiAiAdapter 的自定义 LLM 路由(全量 compat、模型继承、models.dev 目录兜底)",
3
+ "version": "0.1.43",
4
+ "description": "dsh-plus service+ui plugin: 基于 PiAiAdapter 的自定义 LLM 路由(全量 compat、模型继承、pi-ai 内置目录;协议/compat/字段集现场推导,自动跟随已装 dsh)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -31,44 +31,43 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "https-proxy-agent": "^9.1.0",
35
- "@dsh-plus/shared": "0.1.32"
34
+ "@dsh-plus/shared": "0.1.33"
36
35
  },
37
36
  "peerDependencies": {
38
- "@deepseek-ai/cordis": "^4.0.4",
39
- "@deepseek-ai/dsh-anonymous-user-id": "^0.2.0-rc.1",
40
- "@deepseek-ai/dsh-credentials": "^0.2.0-rc.1",
41
- "@deepseek-ai/dsh-home-paths": "^0.2.0-rc.1",
42
- "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
43
- "@deepseek-ai/dsh-launch-environment": "^0.2.0-rc.1",
44
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
45
- "@deepseek-ai/dsh-llm-deepseek": "^0.2.0-rc.1",
46
- "@deepseek-ai/dsh-llm-pi-ai": "^0.2.0-rc.1",
47
- "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
48
- "@deepseek-ai/dsh-util-values": "^0.2.0-rc.1",
49
- "@deepseek-ai/schemastery": "^3.18.4",
50
- "@earendil-works/pi-ai": "^0.85.1"
37
+ "@deepseek-ai/cordis": "^4.0.5-alpha.1",
38
+ "@deepseek-ai/dsh-anonymous-user-id": "^0.2.1-alpha.1",
39
+ "@deepseek-ai/dsh-credentials": "^0.2.1-alpha.1",
40
+ "@deepseek-ai/dsh-home-paths": "^0.2.1-alpha.1",
41
+ "@deepseek-ai/dsh-host-webserver": "^0.2.1-alpha.1",
42
+ "@deepseek-ai/dsh-launch-environment": "^0.2.1-alpha.1",
43
+ "@deepseek-ai/dsh-llm": "^0.2.1-alpha.1",
44
+ "@deepseek-ai/dsh-llm-deepseek": "^0.2.1-alpha.1",
45
+ "@deepseek-ai/dsh-llm-pi-ai": "^0.2.1-alpha.1",
46
+ "@deepseek-ai/dsh-settings": "^0.2.1-alpha.1",
47
+ "@deepseek-ai/dsh-util-values": "^0.2.1-alpha.1",
48
+ "@deepseek-ai/schemastery": "^3.18.5-alpha.1",
49
+ "@earendil-works/pi-ai": ">=0.85.1"
51
50
  },
52
51
  "devDependencies": {
53
- "@deepseek-ai/cordis": "4.0.4",
54
- "@deepseek-ai/dsh-anonymous-user-id": "0.2.0-rc.1",
55
- "@deepseek-ai/dsh-app-boot": "0.2.0-rc.1",
56
- "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.1",
57
- "@deepseek-ai/dsh-attachment": "0.2.0-rc.1",
58
- "@deepseek-ai/dsh-credentials": "0.2.0-rc.1",
59
- "@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
60
- "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.1",
61
- "@deepseek-ai/dsh-launch-environment": "0.2.0-rc.1",
62
- "@deepseek-ai/dsh-llm": "0.2.0-rc.1",
63
- "@deepseek-ai/dsh-llm-deepseek": "0.2.0-rc.1",
64
- "@deepseek-ai/dsh-llm-pi-ai": "0.2.0-rc.1",
65
- "@deepseek-ai/dsh-settings": "0.2.0-rc.1",
66
- "@deepseek-ai/dsh-timeout": "0.2.0-rc.1",
67
- "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.1",
68
- "@deepseek-ai/dsh-util-crypto": "0.2.0-rc.1",
69
- "@deepseek-ai/dsh-util-values": "0.2.0-rc.1",
70
- "@deepseek-ai/schemastery": "3.18.4",
71
- "@earendil-works/pi-ai": "0.85.1",
52
+ "@deepseek-ai/cordis": "4.0.5-alpha.1",
53
+ "@deepseek-ai/dsh-anonymous-user-id": "0.2.1-alpha.1",
54
+ "@deepseek-ai/dsh-app-boot": "0.2.1-alpha.1",
55
+ "@deepseek-ai/dsh-atomic-write": "0.2.1-alpha.1",
56
+ "@deepseek-ai/dsh-attachment": "0.2.1-alpha.1",
57
+ "@deepseek-ai/dsh-credentials": "0.2.1-alpha.1",
58
+ "@deepseek-ai/dsh-home-paths": "0.2.1-alpha.1",
59
+ "@deepseek-ai/dsh-host-webserver": "0.2.1-alpha.1",
60
+ "@deepseek-ai/dsh-launch-environment": "0.2.1-alpha.1",
61
+ "@deepseek-ai/dsh-llm": "0.2.1-alpha.1",
62
+ "@deepseek-ai/dsh-llm-deepseek": "0.2.1-alpha.1",
63
+ "@deepseek-ai/dsh-llm-pi-ai": "0.2.1-alpha.1",
64
+ "@deepseek-ai/dsh-settings": "0.2.1-alpha.1",
65
+ "@deepseek-ai/dsh-timeout": "0.2.1-alpha.1",
66
+ "@deepseek-ai/dsh-typert-protocol": "0.2.1-alpha.1",
67
+ "@deepseek-ai/dsh-util-crypto": "0.2.1-alpha.1",
68
+ "@deepseek-ai/dsh-util-values": "0.2.1-alpha.1",
69
+ "@deepseek-ai/schemastery": "3.18.5-alpha.1",
70
+ "@earendil-works/pi-ai": "0.87.1",
72
71
  "@types/react": "~18.3.31",
73
72
  "react": "^18.2.0"
74
73
  },
@@ -0,0 +1,297 @@
1
+ /**
2
+ * pi-ai 内置目录的浏览/搜索后端(纯函数,node 侧单测直测)。
3
+ *
4
+ * 为什么在服务端搜:浏览器半拿不到 pi-ai(只能读本插件的 HTTP 端点),且
5
+ * 匹配/排序逻辑放服务端才能进 `node --test`(本仓无 DOM 测试设施)。
6
+ * 目录规模(40+ provider / 1500 模型)用按 kit 备忘的全量快照 + 内存过滤完全够用,
7
+ * 每次搜索只把当前页(默认 40 条)序列化给浏览器。
8
+ *
9
+ * 字段不钉死:模型事实由 `builtinModelFacts` 现场读取(见 catalog/builtin.ts),
10
+ * 官方 pi-ai 新增参数字段会自动出现在 `rest` 里,界面按原样展示。
11
+ * @module llm-pi/catalog/browse
12
+ */
13
+
14
+ import type { DshKit } from '../resolve-dsh.ts'
15
+ import {
16
+ type BuiltinModelFacts,
17
+ type BuiltinProviderEntry,
18
+ builtinModelFacts,
19
+ builtinProviderEntries,
20
+ } from './builtin.ts'
21
+
22
+ /** 浏览器可消费的模型条目(纯 JSON;函数面一律不进)。 */
23
+ export interface BuiltinModelInfo {
24
+ provider: string
25
+ /** 发送给 provider 的请求 id。 */
26
+ id: string
27
+ /** `provider/id` 路径 id(extends 引用与复制用)。 */
28
+ path: string
29
+ /** 显示名(pi-ai 目录给的 name)。 */
30
+ name: string
31
+ /** 请求协议。 */
32
+ api: string
33
+ baseUrl: string
34
+ contextWindow: number
35
+ maxTokens: number
36
+ input: ('text' | 'image')[]
37
+ reasoning: boolean
38
+ thinkingLevelMap?: Record<string, string | null>
39
+ compat?: Record<string, unknown>
40
+ /** 目录给出但未单列的其余字段(cost / inputLimits / headers 等),原样展示。 */
41
+ rest: Record<string, unknown>
42
+ /** 当前生效协议集合能否服务该模型(false = 不能加进任何 pi route)。 */
43
+ servable: boolean
44
+ }
45
+
46
+ /** 浏览筛选条件(全部可选;缺省 = 不加该维度过滤)。 */
47
+ export interface BrowseQuery {
48
+ /** 模糊搜索串(空白分隔多词,全部词都要命中)。 */
49
+ q?: string
50
+ /** 供应商(pi-ai provider id)精确匹配。 */
51
+ provider?: string
52
+ /** 请求协议精确匹配。 */
53
+ api?: string
54
+ /** 只要推理模型。 */
55
+ reasoning?: boolean
56
+ /** 只要支持图片输入的模型。 */
57
+ image?: boolean
58
+ /** 只列本插件可服务的模型;缺省 true。 */
59
+ servableOnly?: boolean
60
+ /** 起始下标(缺省 0)。 */
61
+ offset?: number
62
+ /** 每页条数(缺省 {@link BROWSE_DEFAULT_LIMIT},上限 {@link BROWSE_MAX_LIMIT})。 */
63
+ limit?: number
64
+ }
65
+
66
+ export interface BrowseResult {
67
+ /** 命中总数(应用 servableOnly 之后)。 */
68
+ total: number
69
+ /** 仅因协议不可服务而被隐藏的条数(servableOnly 打开时才有意义)。 */
70
+ servableHidden: number
71
+ offset: number
72
+ limit: number
73
+ items: BuiltinModelInfo[]
74
+ }
75
+
76
+ export const BROWSE_DEFAULT_LIMIT = 40
77
+ export const BROWSE_MAX_LIMIT = 100
78
+
79
+ /** 全量快照按 kit 备忘(同一 kit 实例的目录在进程内不变)。 */
80
+ const snapshotCache = new WeakMap<DshKit, BuiltinModelInfo[]>()
81
+
82
+ /** 归一化:小写 + 去掉分隔符(`/ - _ . 空格`),用于子序列匹配。 */
83
+ function compact(text: string): string {
84
+ return text.toLowerCase().replace(/[\s/\-_.]+/g, '')
85
+ }
86
+
87
+ /** 按分隔符切词(小写),用于词首命中判定。 */
88
+ function words(text: string): string[] {
89
+ return text
90
+ .toLowerCase()
91
+ .split(/[\s/\-_.]+/)
92
+ .filter((word) => word.length > 0)
93
+ }
94
+
95
+ /**
96
+ * 模糊匹配得分:越小越相关;不匹配返回 undefined。
97
+ * 分级:完全相等 0 < 整体前缀 1 < 词首前缀 2 < 子串 3 < 归一化相等 4 <
98
+ * 归一化前缀 5 < 首字母缩写 6+(同一档内以命中跨度加权,越紧凑越小)。
99
+ *
100
+ * 缩写匹配要求首字符落在**词首**(`deepseek-flash` 的 `d`、`flash` 的 `f`):
101
+ * 否则任何长路径都会命中任意短查询(实测 `kimi` 会命中
102
+ * `cloudflare-ai-gateway/workers-ai/@cf/ibm-granite/...`——wokers 里的 k 起头也能凑出
103
+ * 子序列),搜索质量崩坏。跳字过多(超过 needle 长度 4 倍)同样判不命中。
104
+ */
105
+ export function fuzzyScore(query: string, target: string): number | undefined {
106
+ const needle = query.toLowerCase()
107
+ if (needle.length === 0) return 0
108
+ const haystack = target.toLowerCase()
109
+ if (haystack === needle) return 0
110
+ if (haystack.startsWith(needle)) return 1
111
+ if (words(target).some((word) => word.startsWith(needle))) return 2
112
+ if (haystack.includes(needle)) return 3
113
+ const compactNeedle = compact(query)
114
+ const compactTarget = compact(target)
115
+ if (compactNeedle.length === 0) return 0
116
+ if (compactTarget === compactNeedle) return 4
117
+ if (compactTarget.startsWith(compactNeedle)) return 5
118
+ if (compactNeedle.length === 1) return 6
119
+ // 首字母缩写:needle 首字符必须是某个词的首字符,其余按顺序命中;
120
+ // 命中跨度越紧凑越相关(同一档内以跨度/上限为小数权重,越紧越小)。
121
+ const first = needle[0] as string
122
+ const spanCap = compactNeedle.length * 4 + 4
123
+ let best: number | undefined
124
+ for (const start of wordStarts(target)) {
125
+ if (haystack[start] !== first) continue
126
+ let cursor = 1
127
+ let last = start
128
+ for (let i = start + 1; i < haystack.length && cursor < compactNeedle.length; i += 1) {
129
+ const ch = haystack[i] as string
130
+ if (/[\s/\-_.]/.test(ch)) continue
131
+ if (ch === compactNeedle[cursor]) {
132
+ cursor += 1
133
+ last = i
134
+ }
135
+ }
136
+ if (cursor < compactNeedle.length) continue
137
+ const span = last - start + 1
138
+ if (span > spanCap) continue
139
+ const score = 6 + span / spanCap
140
+ if (best === undefined || score < best) best = score
141
+ }
142
+ return best
143
+ }
144
+
145
+ /** 词首下标(按分隔符切分的每个词的首字符位置)。 */
146
+ function wordStarts(text: string): number[] {
147
+ const out: number[] = []
148
+ let atStart = true
149
+ for (let i = 0; i < text.length; i += 1) {
150
+ const ch = text[i] as string
151
+ if (/[\s/\-_.]/.test(ch)) {
152
+ atStart = true
153
+ continue
154
+ }
155
+ if (atStart) {
156
+ out.push(i)
157
+ atStart = false
158
+ }
159
+ }
160
+ return out
161
+ }
162
+
163
+ /** 单条记录的多字段得分:取最相关字段;全部字段都不匹配返回 undefined。 */
164
+ function scoreOf(query: string, info: BuiltinModelInfo): number | undefined {
165
+ let best: number | undefined
166
+ for (const field of [info.path, info.id, info.name, info.provider]) {
167
+ const score = fuzzyScore(query, field)
168
+ if (score === undefined) continue
169
+ // path 命中比 id/name 命中更相关(用户复制/extends 用的就是 path)
170
+ const weighted = field === info.path ? score : score + 2
171
+ if (best === undefined || weighted < best) best = weighted
172
+ }
173
+ return best
174
+ }
175
+
176
+ /** 多词 AND:每个词都要有命中,得分取各词之和(词少者更相关由长度自然体现)。 */
177
+ function multiScore(query: string, info: BuiltinModelInfo): number | undefined {
178
+ const tokens = query.split(/\s+/).filter((token) => token.length > 0)
179
+ if (tokens.length === 0) return 0
180
+ let total = 0
181
+ for (const token of tokens) {
182
+ const score = scoreOf(token, info)
183
+ if (score === undefined) return undefined
184
+ total += score
185
+ }
186
+ return total
187
+ }
188
+
189
+ function toInfo(kit: DshKit, facts: BuiltinModelFacts): BuiltinModelInfo {
190
+ return {
191
+ provider: facts.provider,
192
+ id: facts.id,
193
+ path: facts.path,
194
+ name: facts.name,
195
+ api: facts.api,
196
+ baseUrl: facts.baseUrl,
197
+ contextWindow: facts.contextWindow,
198
+ maxTokens: facts.maxTokens,
199
+ input: facts.input,
200
+ reasoning: facts.reasoning,
201
+ ...(facts.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: facts.thinkingLevelMap }),
202
+ ...(facts.compat === undefined ? {} : { compat: facts.compat }),
203
+ rest: facts.rest,
204
+ servable: kit.protocols.includes(facts.api),
205
+ }
206
+ }
207
+
208
+ /** 全量模型条目(按目录顺序:provider 排序即 pi-ai 目录顺序,模型同)。 */
209
+ export function listModelInfos(kit: DshKit): BuiltinModelInfo[] {
210
+ const cached = snapshotCache.get(kit)
211
+ if (cached !== undefined) return cached
212
+ const out: BuiltinModelInfo[] = []
213
+ for (const provider of kit.getBuiltinProviders()) {
214
+ for (const model of kit.getBuiltinModels(provider)) {
215
+ const facts = builtinModelFacts(provider, model)
216
+ if (facts !== undefined) out.push(toInfo(kit, facts))
217
+ }
218
+ }
219
+ snapshotCache.set(kit, out)
220
+ return out
221
+ }
222
+
223
+ /** 供应商条目(含模型数与显示名)。 */
224
+ export function listProviders(kit: DshKit): BuiltinProviderEntry[] {
225
+ return builtinProviderEntries(kit)
226
+ }
227
+
228
+ /**
229
+ * 紧凑协议索引 `{provider: {modelId: api}}`:浏览器半据此推断某个 route 当前
230
+ * 实际生效的协议(route 级 `api` 缺省时由模型继承值决定),用于一键添加前的冲突检查。
231
+ */
232
+ export function buildApiIndex(
233
+ infos: readonly BuiltinModelInfo[],
234
+ ): Record<string, Record<string, string>> {
235
+ const out: Record<string, Record<string, string>> = {}
236
+ for (const info of infos) {
237
+ const perProvider = out[info.provider] ?? {}
238
+ perProvider[info.id] = info.api
239
+ out[info.provider] = perProvider
240
+ }
241
+ return out
242
+ }
243
+
244
+ function matchesFilters(info: BuiltinModelInfo, query: BrowseQuery): boolean {
245
+ if (query.provider !== undefined && query.provider.length > 0 && info.provider !== query.provider)
246
+ return false
247
+ if (query.api !== undefined && query.api.length > 0 && info.api !== query.api) return false
248
+ if (query.reasoning === true && !info.reasoning) return false
249
+ if (query.image === true && !info.input.includes('image')) return false
250
+ return true
251
+ }
252
+
253
+ function clampLimit(value: number | undefined): number {
254
+ if (value === undefined || !Number.isFinite(value) || value <= 0) return BROWSE_DEFAULT_LIMIT
255
+ return Math.min(Math.floor(value), BROWSE_MAX_LIMIT)
256
+ }
257
+
258
+ /**
259
+ * 搜索 + 过滤 + 分页。无搜索串时按目录顺序(provider/id)返回;
260
+ * 有搜索串时按相关度排序,同分按路径字典序稳定。
261
+ */
262
+ export function browseModels(kit: DshKit, query: BrowseQuery = {}): BrowseResult {
263
+ const all = listModelInfos(kit)
264
+ const servableOnly = query.servableOnly !== false
265
+ const limit = clampLimit(query.limit)
266
+ const offset = Math.max(0, Math.floor(query.offset ?? 0))
267
+ const q = (query.q ?? '').trim()
268
+
269
+ let servableHidden = 0
270
+ const matched: { info: BuiltinModelInfo; score: number }[] = []
271
+ for (const info of all) {
272
+ if (!info.servable) {
273
+ if (servableOnly) servableHidden += 1
274
+ if (servableOnly) continue
275
+ }
276
+ if (!matchesFilters(info, query)) continue
277
+ const score = q.length === 0 ? 0 : multiScore(q, info)
278
+ if (score === undefined) continue
279
+ matched.push({ info, score })
280
+ }
281
+ if (q.length > 0) {
282
+ // 同分优先更短的路径(更具体的命中),再按字典序稳定
283
+ matched.sort((left, right) =>
284
+ left.score === right.score
285
+ ? left.info.path.length - right.info.path.length ||
286
+ left.info.path.localeCompare(right.info.path)
287
+ : left.score - right.score,
288
+ )
289
+ }
290
+ return {
291
+ total: matched.length,
292
+ servableHidden,
293
+ offset,
294
+ limit,
295
+ items: matched.slice(offset, offset + limit).map((entry) => entry.info),
296
+ }
297
+ }
@@ -1,14 +1,52 @@
1
1
  /**
2
- * pi-ai 内置目录适配:继承解析的第一级(最高优先级)数据源。
3
- * 内置条目带 pi 官方校正(compat、thinkingLevelMap、能力过滤),最可信。
2
+ * pi-ai 内置目录适配:extends 继承解析的唯一数据源,也是配置页「内置模型目录」
3
+ * 浏览器的数据来源。
4
+ *
5
+ * 两个「不钉死」:
6
+ * - 目录条目随已装 dsh 的 pi-ai 版本自动更新(本文件只读,不复制目录);
7
+ * - 可继承的模型级键集按已装官方适配器 Config schema 现场推导
8
+ * (`kit.officialModelFields`),官方新增模型级字段即自动透传;
9
+ * 目录里出现而官方 schema 不接受的字段(如 cost/headers/inputLimits)
10
+ * 仍会被摘进 facts.rest 供界面展示,但不参与继承。
11
+ *
12
+ * 运行时读取对未知/异形字段宽容(跨版本容忍):只把能确认形状的值纳入事实。
4
13
  * @module llm-pi/catalog/builtin
5
14
  */
6
15
  import type { DshKit } from '../resolve-dsh.ts'
7
16
 
17
+ /** 内置目录的 provider id 字面量联合(pi-ai 生成目录的键集,按 vendored 版本收窄)。 */
18
+ export type BuiltinProviderId = ReturnType<DshKit['getBuiltinProviders']>[number]
19
+
20
+ /** 浏览/状态用的 provider 条目(不含 pi-ai 的函数面,可安全序列化给浏览器半)。 */
21
+ export interface BuiltinProviderEntry {
22
+ id: string
23
+ name: string
24
+ baseUrl?: string
25
+ modelCount: number
26
+ }
27
+
8
28
  /**
9
- * 继承源可提供的模型字段(pi-ai Model 的可继承子集)。
10
- * 全部可选:models.dev 兜底源只给得出其中一部分。
29
+ * 单个内置模型的事实快照(与 pi-ai 类型解耦的运行期读取结果)。
30
+ * `rest` 是目录给出但本插件不认识的其余自有键(展示用,随上游字段增删自动变化)。
11
31
  */
32
+ export interface BuiltinModelFacts {
33
+ provider: string
34
+ id: string
35
+ /** `provider/id` 路径 id(extends 引用与「复制路径 id」用)。 */
36
+ path: string
37
+ name: string
38
+ api: string
39
+ baseUrl: string
40
+ reasoning: boolean
41
+ input: ('text' | 'image')[]
42
+ contextWindow: number
43
+ maxTokens: number
44
+ thinkingLevelMap?: Record<string, string | null>
45
+ compat?: Record<string, unknown>
46
+ rest: Record<string, unknown>
47
+ }
48
+
49
+ /** 继承源可提供的模型字段(pi-ai Model 的可继承子集)。 */
12
50
  export interface ModelBase {
13
51
  name?: string
14
52
  api?: string
@@ -19,17 +57,94 @@ export interface ModelBase {
19
57
  compat?: Record<string, unknown>
20
58
  contextWindow?: number
21
59
  maxTokens?: number
22
- cost?: {
23
- input: number
24
- output: number
25
- cacheRead: number
26
- cacheWrite: number
60
+ /** 官方 schema 接受、且目录给出的其余模型级字段(键集现场推导,浅拷贝)。 */
61
+ extra?: Record<string, unknown>
62
+ }
63
+
64
+ /** 已在 facts 显式取出的键:这些不再进 rest。 */
65
+ const FACT_KEYS = new Set([
66
+ 'id',
67
+ 'provider',
68
+ 'name',
69
+ 'api',
70
+ 'baseUrl',
71
+ 'reasoning',
72
+ 'input',
73
+ 'contextWindow',
74
+ 'maxTokens',
75
+ 'thinkingLevelMap',
76
+ 'compat',
77
+ ])
78
+
79
+ /** JSON 值浅拷贝(对象/数组一层;条目要经 settings 序列化,不可留上游引用)。 */
80
+ export function cloneJsonValue(value: unknown): unknown {
81
+ if (Array.isArray(value)) return [...value]
82
+ if (typeof value === 'object' && value !== null) return { ...(value as Record<string, unknown>) }
83
+ return value
84
+ }
85
+
86
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
88
+ ? (value as Record<string, unknown>)
89
+ : undefined
90
+ }
91
+
92
+ /** 读模态数组:只保留 text/image 两种已知值,其余丢弃(防上游改成别的枚举)。 */
93
+ function readInput(value: unknown): ('text' | 'image')[] {
94
+ if (!Array.isArray(value)) return ['text']
95
+ const out = value.filter((m): m is 'text' | 'image' => m === 'text' || m === 'image')
96
+ return out.length > 0 ? out : ['text']
97
+ }
98
+
99
+ /** 读 thinkingLevelMap:值为字符串或 null,其余(含 undefined 键)丢弃。 */
100
+ function readThinkingLevelMap(value: unknown): Record<string, string | null> | undefined {
101
+ const record = asRecord(value)
102
+ if (record === undefined) return undefined
103
+ const out: Record<string, string | null> = {}
104
+ for (const [level, wire] of Object.entries(record)) {
105
+ if (typeof wire === 'string') out[level] = wire
106
+ else if (wire === null) out[level] = null
27
107
  }
28
- headers?: Record<string, string>
108
+ return Object.keys(out).length > 0 ? out : undefined
29
109
  }
30
110
 
31
- /** 内置目录的 provider id 字面量联合(pi-ai 生成目录的键集)。 */
32
- export type BuiltinProviderId = ReturnType<DshKit['getBuiltinProviders']>[number]
111
+ /**
112
+ * 把运行期模型对象读成事实快照。入参用 unknown:pi-ai Model 的类型随版本变化,
113
+ * 继承/浏览都只依赖此处确认过的字段,未确认的一律进 rest 原样展示。
114
+ */
115
+ export function builtinModelFacts(provider: string, raw: unknown): BuiltinModelFacts | undefined {
116
+ const model = asRecord(raw)
117
+ if (model === undefined) return undefined
118
+ const id = model['id']
119
+ if (typeof id !== 'string' || id.length === 0) return undefined
120
+ const rest: Record<string, unknown> = {}
121
+ for (const [key, value] of Object.entries(model)) {
122
+ if (FACT_KEYS.has(key) || value === undefined) continue
123
+ rest[key] = cloneJsonValue(value)
124
+ }
125
+ const name = model['name']
126
+ const api = model['api']
127
+ const baseUrl = model['baseUrl']
128
+ const contextWindow = model['contextWindow']
129
+ const maxTokens = model['maxTokens']
130
+ const thinkingLevelMap = readThinkingLevelMap(model['thinkingLevelMap'])
131
+ const compat = asRecord(model['compat'])
132
+ return {
133
+ provider,
134
+ id,
135
+ path: `${provider}/${id}`,
136
+ name: typeof name === 'string' && name.length > 0 ? name : id,
137
+ api: typeof api === 'string' ? api : '',
138
+ baseUrl: typeof baseUrl === 'string' ? baseUrl : '',
139
+ reasoning: model['reasoning'] === true,
140
+ input: readInput(model['input']),
141
+ contextWindow: typeof contextWindow === 'number' ? contextWindow : 0,
142
+ maxTokens: typeof maxTokens === 'number' ? maxTokens : 0,
143
+ ...(thinkingLevelMap === undefined ? {} : { thinkingLevelMap }),
144
+ ...(compat === undefined ? {} : { compat: { ...compat } }),
145
+ rest,
146
+ }
147
+ }
33
148
 
34
149
  /** 内置 provider 的端点(provider 级 extends 的 baseURL 缺省值)。 */
35
150
  export function builtinProviderBaseUrl(kit: DshKit, provider: string): string | undefined {
@@ -45,40 +160,73 @@ export function hasBuiltinProvider(kit: DshKit, provider: string): provider is B
45
160
  return ids.includes(provider)
46
161
  }
47
162
 
48
- /** 内置 provider 的全部模型 id(UI extends 选择器用)。 */
163
+ /** 内置 provider 的全部模型 id(UI 选择器/浏览用)。 */
49
164
  export function builtinModelIds(kit: DshKit, provider: string): string[] {
50
165
  if (!hasBuiltinProvider(kit, provider)) return []
51
166
  return kit.getBuiltinModels(provider).map((m) => m.id)
52
167
  }
53
168
 
54
- /** 查单个内置模型为继承 base;未命中返回 undefined。 */
55
- export function builtinModelBase(
169
+ /** 内置目录的全部 provider 条目(含 pi-ai 给的显示名与端点)。 */
170
+ export function builtinProviderEntries(kit: DshKit): BuiltinProviderEntry[] {
171
+ const catalog = kit.builtinProviders()
172
+ const byId = new Map(catalog.map((p) => [p.id as string, p]))
173
+ return kit.getBuiltinProviders().map((id) => {
174
+ const provider = byId.get(id)
175
+ const baseUrl = provider?.baseUrl
176
+ return {
177
+ id,
178
+ name: provider?.name ?? id,
179
+ ...(typeof baseUrl === 'string' && baseUrl.length > 0 ? { baseUrl } : {}),
180
+ modelCount: kit.getBuiltinModels(id).length,
181
+ }
182
+ })
183
+ }
184
+
185
+ /** 取某 provider 下某模型的事实快照;provider/模型不存在返回 undefined。 */
186
+ export function builtinModelFactsOf(
56
187
  kit: DshKit,
57
188
  provider: string,
58
189
  modelId: string,
59
- ): ModelBase | undefined {
190
+ ): BuiltinModelFacts | undefined {
60
191
  if (!hasBuiltinProvider(kit, provider)) return undefined
61
192
  const model = kit.getBuiltinModels(provider).find((m) => m.id === modelId)
62
- if (model === undefined) return undefined
193
+ return model === undefined ? undefined : builtinModelFacts(provider, model)
194
+ }
195
+
196
+ /** 事实快照 → 继承 base(`extra` 只包含官方 schema 接受的其余模型级键)。 */
197
+ export function modelBaseOf(kit: DshKit, facts: BuiltinModelFacts): ModelBase {
198
+ const extra: Record<string, unknown> = {}
199
+ for (const field of kit.officialModelFields) {
200
+ if (FACT_KEYS.has(field) || field === 'reasoningEfforts') continue
201
+ const value = facts.rest[field]
202
+ if (value !== undefined) extra[field] = cloneJsonValue(value)
203
+ }
63
204
  return {
64
- name: model.name,
65
- api: model.api,
66
- baseUrl: model.baseUrl,
67
- input: [...model.input],
68
- reasoning: model.reasoning,
69
- ...(model.thinkingLevelMap === undefined
70
- ? {}
71
- : { thinkingLevelMap: { ...model.thinkingLevelMap } }),
72
- ...(model.compat === undefined
205
+ name: facts.name,
206
+ api: facts.api,
207
+ baseUrl: facts.baseUrl,
208
+ input: [...facts.input],
209
+ reasoning: facts.reasoning,
210
+ ...(facts.thinkingLevelMap === undefined
73
211
  ? {}
74
- : { compat: { ...(model.compat as Record<string, unknown>) } }),
75
- contextWindow: model.contextWindow,
76
- maxTokens: model.maxTokens,
77
- cost: { ...model.cost },
78
- ...(model.headers === undefined ? {} : { headers: { ...model.headers } }),
212
+ : { thinkingLevelMap: { ...facts.thinkingLevelMap } }),
213
+ ...(facts.compat === undefined ? {} : { compat: { ...facts.compat } }),
214
+ contextWindow: facts.contextWindow,
215
+ maxTokens: facts.maxTokens,
216
+ ...(Object.keys(extra).length === 0 ? {} : { extra }),
79
217
  }
80
218
  }
81
219
 
220
+ /** 查单个内置模型为继承 base;未命中返回 undefined。 */
221
+ export function builtinModelBase(
222
+ kit: DshKit,
223
+ provider: string,
224
+ modelId: string,
225
+ ): ModelBase | undefined {
226
+ const facts = builtinModelFactsOf(kit, provider, modelId)
227
+ return facts === undefined ? undefined : modelBaseOf(kit, facts)
228
+ }
229
+
82
230
  /**
83
231
  * provider 级 extends 的全量模型继承:route 不写 models 时,
84
232
  * 以继承源 provider 的全部内置模型作为条目(每个条目 base 即其自身)。
@@ -88,8 +236,8 @@ export function inheritedCatalogEntries(
88
236
  provider: string,
89
237
  ): { id: string; base: ModelBase }[] {
90
238
  if (!hasBuiltinProvider(kit, provider)) return []
91
- return kit.getBuiltinModels(provider).map((model) => ({
92
- id: model.id,
93
- base: builtinModelBase(kit, provider, model.id) ?? {},
94
- }))
239
+ return kit.getBuiltinModels(provider).map((model) => {
240
+ const facts = builtinModelFacts(provider, model)
241
+ return { id: model.id, base: facts === undefined ? {} : modelBaseOf(kit, facts) }
242
+ })
95
243
  }