@dsh-plus/llm-pi 0.1.41 → 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/src/service.ts CHANGED
@@ -8,6 +8,11 @@
8
8
  * handle.replace 重注册;写入被校验拒绝时保留旧注册(官方同款护栏);
9
9
  * - registerConfigurableProviders + registerModelDiscovery 让插件 route
10
10
  * 正常出现在官方 Models 页与"拉取可用模型"动作里。
11
+ *
12
+ * 模型目录唯一来自 pi-ai 内置目录(随已装 dsh 自动跟随),故无目录拉取/缓存;
13
+ * 运行期事实(生效版本、协议集合、目录规模、逐项降级诊断)经 kitInfo() 供配置页展示。
14
+ * 各步骤各拆成独立工厂函数(套件加载 / profiles 备忘 / adapter / 注册 / 目录),
15
+ * startRuntime 只负责编排。
11
16
  * @module llm-pi/service
12
17
  */
13
18
  import type { Context } from '@deepseek-ai/cordis'
@@ -20,9 +25,10 @@ import type { DirectoryRegistrationHandle } from '@deepseek-ai/dsh-llm'
20
25
  import type { ResolvedPiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
21
26
  import type {} from '@deepseek-ai/dsh-settings'
22
27
  import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
23
- import { hasVolatileRefs, pluginDataPath, unwrapVolatile } from '@dsh-plus/shared'
28
+ import { hasVolatileRefs, unwrapVolatile } from '@dsh-plus/shared'
24
29
 
25
- import { ModelsDevSource } from './catalog/models-dev.ts'
30
+ import { buildApiIndex, listModelInfos, listProviders } from './catalog/browse.ts'
31
+ import { compatTableInfo } from './compat.ts'
26
32
  import {
27
33
  Config,
28
34
  type LlmPiConfig,
@@ -33,20 +39,58 @@ import {
33
39
  import { DeepseekRouteRegistrar } from './deepseek-routes.ts'
34
40
  import { buildDirectoryEntries, commitDirectory, type DirectoryEntry } from './directory.ts'
35
41
  import { discoverModels } from './discovery.ts'
42
+ import { type KitVersions, piAiVersionNotice, VERIFIED_PI_AI_RANGE } from './kit-meta.ts'
36
43
  import { assertServiceable, buildProfiles, isDraftRoute } from './profiles.ts'
37
44
  import { buildDeepseekRoutes, type ResolvedDeepseekRoute } from './profiles-deepseek.ts'
38
45
  import { type DshKit, resolveDshKit } from './resolve-dsh.ts'
39
46
 
47
+ /** 配置页「运行期状态」所需的事实(纯 JSON,可直接进 HTTP 响应)。 */
48
+ export interface LlmPiKitInfo {
49
+ /** 套件来源:dsh-tree / vendored。 */
50
+ source: string
51
+ /** 套件所在根(dsh 安装树根或 vendored 副本目录)。 */
52
+ root?: string
53
+ /** 实际生效版本(不是 manifest 上写的版本)。 */
54
+ versions: KitVersions
55
+ /** 本插件验证过的 pi-ai 区间(仅提示)。 */
56
+ verifiedRange: string
57
+ /** 生效版本超出验证区间时的提示文案;区间内/版本未知时省略。 */
58
+ versionNotice?: string
59
+ /** 当前可服务的线协议。 */
60
+ protocols: string[]
61
+ /** 协议集合来源:official(官方 supportedProtocols())/ fallback(内置三元组)。 */
62
+ protocolSource: string
63
+ /** 内置目录规模与数据生成时间。 */
64
+ catalog: { providers: number; models: number; generatedAt?: number }
65
+ /** compat 门控表来源:official / fallback。 */
66
+ compatSource: string
67
+ /** 回退与逐项降级诊断(有内容时界面显式展示)。 */
68
+ diagnostics: string[]
69
+ }
70
+
40
71
  export interface LlmPiRuntime {
41
72
  /** 当前生效配置(settings 用户层解析结果或 cordis 行级 config)。 */
42
73
  currentConfig(): LlmPiConfig
43
- /** 运行时套件来源(dsh-tree / vendored)与回退诊断。 */
44
- kitInfo(): { source: string; diagnostics: string[] }
45
- /** models.dev 兜底源(配置卡片读状态用)。 */
46
- modelsDev: ModelsDevSource
74
+ /** 运行期套件事实(配置卡片状态行 / meta 端点)。 */
75
+ kitInfo(): LlmPiKitInfo
76
+ /** 内置 provider 条目(meta 端点)。 */
77
+ providerEntries(): ReturnType<typeof listProviders>
78
+ /** 紧凑协议索引 `{provider: {modelId: api}}`(卡片推断 route 生效协议)。 */
79
+ apiIndex(): ReturnType<typeof buildApiIndex>
47
80
  kit: DshKit
48
81
  }
49
82
 
83
+ type PiAdapter = InstanceType<DshKit['PiAiAdapter']>
84
+ type ProfileMap = Map<string, ResolvedPiAiProviderProfile>
85
+ type DeepseekMap = Map<string, ResolvedDeepseekRoute>
86
+
87
+ /** 插件日志窄面(ctx.logger 的消费子集;便于把各步骤拆成独立函数)。 */
88
+ interface PluginLogger {
89
+ info(message: string): void
90
+ warn(message: unknown): void
91
+ error(message: unknown): void
92
+ }
93
+
50
94
  /** 注册时捕获的事实表;变化才重注册(按 provider 排序,免序误报)。 */
51
95
  function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>) {
52
96
  return [...profiles.entries()]
@@ -81,47 +125,41 @@ function makeResolveApiKey(ctx: Context, kit: DshKit) {
81
125
  }
82
126
  }
83
127
 
84
- /** 启动插件运行时:解析套件、挂载注册/发现/settings 联动。 */
85
- export async function startRuntime(
128
+ /** 解析运行期套件并落启动日志(来源/生效版本/协议/逐项降级诊断)。 */
129
+ async function loadRuntimeKit(
86
130
  ctx: Context,
87
- rawConfig: LlmPiConfig | LlmPiConfigFields,
88
- ): Promise<LlmPiRuntime> {
89
- const logger = ctx.logger('llm-pi')
90
- // cordis 行级 config 可能未经 schema 解析(insert 行无 config 键时为原始空对象),
91
- // 在此统一规范化;0.1.7 loader 解析出的 volatile 活动字段形态直接透传
92
- // (hasVolatileRefs 辨识——重复校验会把引用再包一层、破坏 loader 原位提交)。
93
- const fields: LlmPiConfigFields = hasVolatileRefs(rawConfig)
94
- ? (rawConfig as LlmPiConfigFields)
95
- : Config((rawConfig ?? {}) as LlmPiConfigInput)
131
+ logger: PluginLogger,
132
+ ): Promise<{ kit: DshKit; diagnostics: string[] }> {
96
133
  const { kit, diagnostics } = await resolveDshKit(ctx.get('profileContext')?.installAnchor)
97
134
  for (const line of diagnostics) logger.warn(line)
98
- logger.info(`运行时套件来源:${kit.source}`)
99
-
100
- // 活动引用解包出平面快照:identity 仅在 volatile-update 重解包时换代
101
- // (profiles 备忘依赖 raw === lastRaw;非 volatile 变更走 fiber reload 整树重建)。
102
- let snapshot = unwrapVolatile(fields)
103
- const modelsDev = new ModelsDevSource(
104
- pluginDataPath('llm-pi', 'models-dev.json'),
105
- snapshot.catalogUrl,
106
- snapshot.catalogRefreshHours,
107
- (message) => logger.warn(message),
108
- snapshot.catalogProxy ?? '',
135
+ logger.info(
136
+ `运行时套件来源:${kit.source};pi-ai ${kit.versions.piAi ?? '?'} / ` +
137
+ `dsh-llm-pi-ai ${kit.versions.piAiAdapter ?? '?'};协议 ${kit.protocols.join('/')}`,
109
138
  )
110
- void modelsDev.ensureLoaded()
139
+ const versionNotice = piAiVersionNotice(kit.versions.piAi)
140
+ if (versionNotice !== undefined) logger.warn(versionNotice)
141
+ return { kit, diagnostics }
142
+ }
111
143
 
112
- const current: () => LlmPiConfig = () => snapshot
144
+ /**
145
+ * profiles / deepseek 路由解析器:按原始 config 对象 identity 备忘(官方同款模式)。
146
+ * 运行期走 lenient:目录漂移时降级/跳过并告警,而非抛错弄挂整个 route。
147
+ */
148
+ function createProfileResolvers(options: {
149
+ kit: DshKit
150
+ current: () => LlmPiConfig
151
+ logger: PluginLogger
152
+ }): { profiles(): ProfileMap; deepseekRoutes(): DeepseekMap } {
153
+ const { kit, current, logger } = options
113
154
  let lastRaw: LlmPiConfig | undefined
114
- let memoized: Map<string, ResolvedPiAiProviderProfile> | undefined
115
- let memoizedDeepseek: Map<string, ResolvedDeepseekRoute> | undefined
116
- const deps = { kit, modelsDev }
117
- /** 当前已解析 profiles,按原始 config identity 备忘(官方同款模式)。
118
- * 运行期走 lenient:数据源漂移时降级/跳过并告警,而非抛错弄挂整个 route。 */
119
- const profiles = (): Map<string, ResolvedPiAiProviderProfile> => {
155
+ let memoized: ProfileMap | undefined
156
+ let memoizedDeepseek: DeepseekMap | undefined
157
+ const profiles = (): ProfileMap => {
120
158
  const raw = current()
121
159
  if (raw === lastRaw && memoized !== undefined) return memoized
122
160
  const next = raw.enabled
123
161
  ? buildProfiles(raw.providers, {
124
- ...deps,
162
+ kit,
125
163
  lenient: true,
126
164
  warn: (message) => logger.warn(message),
127
165
  })
@@ -137,20 +175,28 @@ export async function startRuntime(
137
175
  memoized = next
138
176
  return next
139
177
  }
140
- /** deepseek 路由物化表(与 profiles 同一次备忘窗口)。 */
141
- const deepseekRoutes = (): Map<string, ResolvedDeepseekRoute> => {
178
+ const deepseekRoutes = (): DeepseekMap => {
142
179
  profiles()
143
180
  return memoizedDeepseek ?? new Map()
144
181
  }
145
- profiles() // 行级 config 不可服务则启动即失败(官方同款 fail-fast)
182
+ return { profiles, deepseekRoutes }
183
+ }
146
184
 
147
- const adapter = new kit.PiAiAdapter({
185
+ /** 构造 PiAiAdapter(官方给出的接缝全部接上)。 */
186
+ function createAdapter(options: {
187
+ ctx: Context
188
+ kit: DshKit
189
+ profiles(): ProfileMap
190
+ logger: PluginLogger
191
+ }): PiAdapter {
192
+ const { ctx, kit, profiles, logger } = options
193
+ return new kit.PiAiAdapter({
148
194
  profiles,
149
195
  resolveApiKey: makeResolveApiKey(ctx, kit),
150
196
  resolveAttachments: () => ctx.get('attachments'),
151
- // 0.1.2-alpha.2 必需(包根仍未导出官方 auth 助手):登录/OAuth 类 provider 与 pi-ai 自有凭据写入的落点。
152
- // dsh 树 dev 布局(src/auth.ts 存在)时为官方助手,其余形态为内联等价
153
- // 实现(resolve-dsh.ts 探测,见 auth-inline.ts)。
197
+ // 0.1.2-alpha.2 必需(包根仍未导出官方 auth 助手):登录/OAuth 类 provider 与
198
+ // pi-ai 自有凭据写入的落点。dsh 树 dev 布局(src/auth.ts 存在)时为官方助手,
199
+ // 其余形态为内联等价实现(resolve-dsh.ts 探测,见 auth-inline.ts)。
154
200
  auth: {
155
201
  credentials: kit.auth.credentialStoreFrom(ctx),
156
202
  authContext: kit.auth.authContextFrom(ctx),
@@ -175,26 +221,20 @@ export async function startRuntime(
175
221
  )
176
222
  },
177
223
  })
224
+ }
178
225
 
179
- const storedApiKey = async (provider: string | undefined): Promise<string | undefined> => {
180
- if (provider === undefined) return undefined
181
- const profile = profiles().get(provider)
182
- if (profile === undefined) return undefined
183
- return makeResolveApiKey(ctx, kit)(provider, profile)
184
- }
185
- ctx.llm.registerModelDiscovery(SETTINGS_NS, (request: Parameters<typeof discoverModels>[0]) =>
186
- discoverModels(request, {
187
- kit,
188
- configProviders: () => current().providers ?? {},
189
- storedApiKey,
190
- }),
191
- )
192
-
193
- /**
194
- * 注册 handle 组。正常路径单个 handle 整批注册/替换;整批注册遇
195
- * DUPLICATE_ADAPTER(route 名与其他 adapter 冲突)时降级为逐个注册,
196
- * 跳过冲突 route——启动不再 fail-loud,其余 route 照常服务。
197
- */
226
+ /**
227
+ * 注册 handle 组。正常路径单个 handle 整批注册/替换;整批注册遇
228
+ * DUPLICATE_ADAPTER(route 名与其他 adapter 冲突)时降级为逐个注册,
229
+ * 跳过冲突 route——启动不再 fail-loud,其余 route 照常服务。
230
+ */
231
+ function createRegistrationController(options: {
232
+ ctx: Context
233
+ adapter: PiAdapter
234
+ profiles(): ProfileMap
235
+ logger: PluginLogger
236
+ }): { ensureRegistration(): void } {
237
+ const { ctx, adapter, profiles, logger } = options
198
238
  interface RegistrationGroup {
199
239
  routes: string[]
200
240
  handle: { replace(routes: string[]): void }
@@ -228,10 +268,10 @@ export async function startRuntime(
228
268
  }
229
269
 
230
270
  const ensureRegistration = (): void => {
231
- const current2 = profiles()
232
- const facts = registrationFacts(current2)
271
+ const resolved = profiles()
272
+ const facts = registrationFacts(resolved)
233
273
  if (deepEqualJson(facts, registeredFacts)) return
234
- const routes = [...current2.keys()]
274
+ const routes = [...resolved.keys()]
235
275
  if (registrations === undefined) {
236
276
  if (routes.length === 0) {
237
277
  registeredFacts = facts
@@ -256,10 +296,24 @@ export async function startRuntime(
256
296
  }
257
297
  registeredFacts = facts
258
298
  }
299
+ return { ensureRegistration }
300
+ }
259
301
 
302
+ /**
303
+ * 可配置 provider 目录(pi 路由 + deepseek 路由 + 草稿路由)。
304
+ * 草稿路由从原始配置直读——它们不进 adapter profiles。
305
+ */
306
+ function createDirectoryController(options: {
307
+ ctx: Context
308
+ kit: DshKit
309
+ current: () => LlmPiConfig
310
+ profiles(): ProfileMap
311
+ deepseekRoutes(): DeepseekMap
312
+ logger: PluginLogger
313
+ }): { ensureDirectory(): void } {
314
+ const { ctx, kit, current, profiles, deepseekRoutes, logger } = options
260
315
  let directory: DirectoryRegistrationHandle | undefined
261
316
  let directoryFacts: unknown
262
- /** 草稿路由(无模型占位)从原始配置直读——它们不进 adapter profiles。 */
263
317
  const draftRoutes = (): { route: string; displayName: string }[] => {
264
318
  const providers = current().providers ?? {}
265
319
  return Object.entries(providers)
@@ -277,7 +331,7 @@ export async function startRuntime(
277
331
  // 备忘键为目标全集(含被冲突跳过的条目):目标不变不重复尝试/告警
278
332
  if (deepEqualJson(entries, directoryFacts)) return
279
333
  if (entries.length === 0) {
280
- // 空目录不可注册(INVALID_DIRECTORY);等 settings 用户层供数后在 onChange 注册
334
+ // 空目录不可注册(INVALID_DIRECTORY);等 settings 用户层供数后再注册
281
335
  directoryFacts = entries
282
336
  return
283
337
  }
@@ -291,7 +345,65 @@ export async function startRuntime(
291
345
  )
292
346
  directoryFacts = entries
293
347
  }
348
+ return { ensureDirectory }
349
+ }
294
350
 
351
+ /** 跑一步热更新动作,失败只告警(保留此前注册/目录)。 */
352
+ function attempt(logger: PluginLogger, message: string, run: () => void): void {
353
+ try {
354
+ run()
355
+ } catch (error) {
356
+ logger.error(message)
357
+ logger.error(error)
358
+ }
359
+ }
360
+
361
+ /** 启动插件运行时:解析套件、挂载注册/发现/settings 联动。 */
362
+ export async function startRuntime(
363
+ ctx: Context,
364
+ rawConfig: LlmPiConfig | LlmPiConfigFields,
365
+ ): Promise<LlmPiRuntime> {
366
+ const logger = ctx.logger('llm-pi')
367
+ // cordis 行级 config 可能未经 schema 解析(insert 行无 config 键时为原始空对象),
368
+ // 在此统一规范化;0.1.7 loader 解析出的 volatile 活动字段形态直接透传
369
+ // (hasVolatileRefs 辨识——重复校验会把引用再包一层、破坏 loader 原位提交)。
370
+ const fields: LlmPiConfigFields = hasVolatileRefs(rawConfig)
371
+ ? (rawConfig as LlmPiConfigFields)
372
+ : Config((rawConfig ?? {}) as LlmPiConfigInput)
373
+ const { kit, diagnostics } = await loadRuntimeKit(ctx, logger)
374
+
375
+ // 活动引用解包出平面快照:identity 仅在 volatile-update 重解包时换代
376
+ // (profiles 备忘依赖 raw === lastRaw;非 volatile 变更走 fiber reload 整树重建)。
377
+ let snapshot = unwrapVolatile(fields)
378
+ const current: () => LlmPiConfig = () => snapshot
379
+ const deps = { kit }
380
+ const { profiles, deepseekRoutes } = createProfileResolvers({ kit, current, logger })
381
+ profiles() // 行级 config 不可服务则启动即失败(官方同款 fail-fast)
382
+
383
+ const adapter = createAdapter({ ctx, kit, profiles, logger })
384
+ const storedApiKey = async (provider: string | undefined): Promise<string | undefined> => {
385
+ if (provider === undefined) return undefined
386
+ const profile = profiles().get(provider)
387
+ if (profile === undefined) return undefined
388
+ return makeResolveApiKey(ctx, kit)(provider, profile)
389
+ }
390
+ ctx.llm.registerModelDiscovery(SETTINGS_NS, (request: Parameters<typeof discoverModels>[0]) =>
391
+ discoverModels(request, {
392
+ kit,
393
+ configProviders: () => current().providers ?? {},
394
+ storedApiKey,
395
+ }),
396
+ )
397
+
398
+ const { ensureRegistration } = createRegistrationController({ ctx, adapter, profiles, logger })
399
+ const { ensureDirectory } = createDirectoryController({
400
+ ctx,
401
+ kit,
402
+ current,
403
+ profiles,
404
+ deepseekRoutes,
405
+ logger,
406
+ })
295
407
  const deepseekRegistrar = new DeepseekRouteRegistrar({
296
408
  ctx,
297
409
  kit,
@@ -310,26 +422,9 @@ export async function startRuntime(
310
422
  // 同款):configEditor.edit 落盘前先跑 waterfall,handler throw 即拒绝整笔写入。
311
423
  const reconfigure = (): void => {
312
424
  snapshot = unwrapVolatile(fields)
313
- try {
314
- ensureRegistration()
315
- } catch (error) {
316
- logger.error('llm-pi: 更新被拒,保留此前注册的 route')
317
- logger.error(error)
318
- }
319
- try {
320
- ensureDeepseek()
321
- } catch (error) {
322
- logger.error('llm-pi: deepseek route 更新失败,保留此前注册')
323
- logger.error(error)
324
- }
325
- try {
326
- ensureDirectory()
327
- } catch (error) {
328
- logger.error('llm-pi: 更新被拒,保留此前的 configurable-provider 目录')
329
- logger.error(error)
330
- }
331
- const cfg = current()
332
- modelsDev.reconfigure(cfg.catalogUrl, cfg.catalogRefreshHours, cfg.catalogProxy ?? '')
425
+ attempt(logger, 'llm-pi: 更新被拒,保留此前注册的 route', ensureRegistration)
426
+ attempt(logger, 'llm-pi: deepseek route 更新失败,保留此前注册', ensureDeepseek)
427
+ attempt(logger, 'llm-pi: 更新被拒,保留此前的 configurable-provider 目录', ensureDirectory)
333
428
  }
334
429
  ctx.events.on('loader/volatile-update', reconfigure)
335
430
  ctx.on('internal/config', function (_raw: unknown, next: () => unknown) {
@@ -339,10 +434,34 @@ export async function startRuntime(
339
434
  return candidate
340
435
  })
341
436
 
437
+ /** 目录规模:按需从当前套件的 provider/模型清单汇总(不缓存,随套件固定)。 */
438
+ const kitInfo = (): LlmPiKitInfo => {
439
+ const providers = listProviders(kit)
440
+ const generatedAt = kit.catalogGeneratedAt
441
+ const notice = piAiVersionNotice(kit.versions.piAi)
442
+ return {
443
+ source: kit.source,
444
+ ...(kit.root === undefined ? {} : { root: kit.root }),
445
+ versions: kit.versions,
446
+ verifiedRange: VERIFIED_PI_AI_RANGE,
447
+ ...(notice === undefined ? {} : { versionNotice: notice }),
448
+ protocols: kit.protocols,
449
+ protocolSource: kit.protocolSource,
450
+ catalog: {
451
+ providers: providers.length,
452
+ models: providers.reduce((total, entry) => total + entry.modelCount, 0),
453
+ ...(generatedAt === undefined ? {} : { generatedAt }),
454
+ },
455
+ compatSource: compatTableInfo().source,
456
+ diagnostics,
457
+ }
458
+ }
459
+
342
460
  return {
343
461
  currentConfig: () => current(),
344
- kitInfo: () => ({ source: kit.source, diagnostics }),
345
- modelsDev,
462
+ kitInfo,
463
+ providerEntries: () => listProviders(kit),
464
+ apiIndex: () => buildApiIndex(listModelInfos(kit)),
346
465
  kit,
347
466
  }
348
467
  }
@@ -1,261 +0,0 @@
1
- /**
2
- * models.dev 兜底目录源:继承解析的第二级数据源。
3
- *
4
- * 只用于内置目录尚未收录的新模型/新供应商。数据是公开快照
5
- *(默认 https://models.dev/api.json),**默认不自动拉取**(catalogRefreshHours=0);
6
- * 拉取方式二选一:
7
- * - 配置 catalogRefreshHours > 0:启动/过期后后台自动刷新;
8
- * - 配置卡片「手动拉取」或 POST /catalog/refresh:立即拉取。
9
- * 拉取可经 catalogProxy 代理(HTTP 代理,如 http://127.0.0.1:7890)。
10
- * 成功落盘缓存($DSH_HOME/dsh-plus/llm-pi/models-dev.json);任何失败都退化为
11
- * "仅内置目录",绝不阻塞 route 注册。
12
- *
13
- * 保守原则:models.dev 数据未经 pi 官方校正(无 compat/thinkingLevelMap),
14
- * 且模态声明不被采信——继承自本源的模型 input 一律走 text-only 兜底,
15
- * 视觉等模态须用户在条目上显式声明(防 over-claiming 导致会话重复失败请求)。
16
- * @module llm-pi/catalog/models-dev
17
- */
18
-
19
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
- import { request as httpRequest } from 'node:http'
21
- import { request as httpsRequest } from 'node:https'
22
- import { dirname } from 'node:path'
23
-
24
- import { HttpsProxyAgent } from 'https-proxy-agent'
25
-
26
- import type { ModelBase } from './builtin.ts'
27
-
28
- export interface ModelsDevStatus {
29
- fetchedAt: string | null
30
- providers: number
31
- models: number
32
- error: string | null
33
- }
34
-
35
- interface ModelsDevModelEntry {
36
- id?: string
37
- name?: string
38
- reasoning?: boolean
39
- limit?: { context?: number; output?: number }
40
- }
41
-
42
- interface ModelsDevProviderEntry {
43
- id?: string
44
- name?: string
45
- models?: Record<string, ModelsDevModelEntry>
46
- }
47
-
48
- type ModelsDevDocument = Record<string, ModelsDevProviderEntry>
49
-
50
- interface CacheFile {
51
- fetchedAt: string
52
- data: ModelsDevDocument
53
- }
54
-
55
- const FETCH_TIMEOUT_MS = 20000
56
- /** 目录文档体上限(api.json 全量约 1-2MB,放宽到 20MB 防未来膨胀)。 */
57
- const MAX_RESPONSE_BYTES = 20 * 1024 * 1024
58
-
59
- interface JsonResponse {
60
- status: number
61
- body: string
62
- }
63
-
64
- /**
65
- * 极简 JSON GET(node:http(s) 实现):支持 HTTP 代理(仅 https 目标)与超时。
66
- * 不跟随重定向(models.dev 直链无重定向;自定义端点需自行保证可直达)。
67
- */
68
- function fetchJson(url: string, proxy: string, timeoutMs: number): Promise<JsonResponse> {
69
- return new Promise((resolve, reject) => {
70
- const target = new URL(url)
71
- const request = target.protocol === 'https:' ? httpsRequest : httpRequest
72
- const agent =
73
- target.protocol === 'https:' && proxy.length > 0 ? new HttpsProxyAgent(proxy) : undefined
74
- const req = request(
75
- url,
76
- { agent, timeout: timeoutMs, headers: { accept: 'application/json' } },
77
- (response) => {
78
- const chunks: Buffer[] = []
79
- let size = 0
80
- let settled = false
81
- const fail = (error: Error): void => {
82
- if (settled) return
83
- settled = true
84
- reject(error)
85
- }
86
- response.on('data', (chunk: Buffer) => {
87
- size += chunk.length
88
- if (size > MAX_RESPONSE_BYTES) {
89
- req.destroy(new Error('响应超过 20MB 上限'))
90
- return
91
- }
92
- chunks.push(chunk)
93
- })
94
- response.on('end', () => {
95
- if (settled) return
96
- settled = true
97
- resolve({
98
- status: response.statusCode ?? 0,
99
- body: Buffer.concat(chunks).toString('utf8'),
100
- })
101
- })
102
- // 响应流自身出错(中途截断的 aborted 等)不经请求转发:缺此监听时
103
- // Promise 永不 settle(req timeout 只覆盖建连/空闲等待),refresh 永久挂起。
104
- response.on('error', fail)
105
- },
106
- )
107
- req.on('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms)`)))
108
- req.on('error', reject)
109
- req.end()
110
- })
111
- }
112
-
113
- function isDocument(value: unknown): value is ModelsDevDocument {
114
- return typeof value === 'object' && value !== null && !Array.isArray(value)
115
- }
116
-
117
- /** 单个 models.dev 模型条目 → 继承 base(仅采信名称/容量/推理能力)。 */
118
- function toModelBase(entry: ModelsDevModelEntry): ModelBase {
119
- const base: ModelBase = {}
120
- if (typeof entry.name === 'string' && entry.name.length > 0) base.name = entry.name
121
- const context = entry.limit?.context
122
- if (typeof context === 'number' && Number.isInteger(context) && context > 0)
123
- base.contextWindow = context
124
- const output = entry.limit?.output
125
- if (typeof output === 'number' && Number.isInteger(output) && output > 0) base.maxTokens = output
126
- base.reasoning = entry.reasoning === true
127
- return base
128
- }
129
-
130
- export class ModelsDevSource {
131
- private readonly cacheFile: string
132
- private url: string
133
- private ttlHours: number
134
- private proxy: string
135
- private readonly log: (message: string) => void
136
-
137
- private document: ModelsDevDocument | undefined
138
- private fetchedAt: string | null = null
139
- private lastError: string | null = null
140
- private refreshing: Promise<void> | undefined
141
-
142
- constructor(
143
- cacheFile: string,
144
- url: string,
145
- ttlHours: number,
146
- log: (message: string) => void,
147
- proxy = '',
148
- ) {
149
- this.cacheFile = cacheFile
150
- this.url = url
151
- this.ttlHours = ttlHours
152
- this.log = log
153
- this.proxy = proxy
154
- }
155
-
156
- /** 配置变更时更新端点/TTL/代理并触发刷新(去抖由 refresh 的进行中复用承担)。 */
157
- reconfigure(url: string, ttlHours: number, proxy: string): void {
158
- if (url === this.url && ttlHours === this.ttlHours && proxy === this.proxy) return
159
- this.url = url
160
- this.ttlHours = ttlHours
161
- this.proxy = proxy
162
- if (ttlHours > 0 && this.isStale()) void this.refresh()
163
- }
164
-
165
- /** 是否已有可用数据(缓存或拉取成功);与自动拉取开关无关。 */
166
- get enabled(): boolean {
167
- return this.document !== undefined
168
- }
169
-
170
- /** 加载缓存,并在启用自动拉取且缓存过期时后台刷新;构造后调用一次,永不抛错。 */
171
- async ensureLoaded(): Promise<void> {
172
- this.loadCache()
173
- if (this.ttlHours > 0 && this.isStale()) await this.refresh()
174
- }
175
-
176
- /** 强制刷新(手动拉取/配置变更触发);ttlHours=0 时同样生效(手动拉取不受自动开关限制)。 */
177
- async refresh(): Promise<void> {
178
- this.refreshing ??= this.doFetch().finally(() => {
179
- this.refreshing = undefined
180
- })
181
- await this.refreshing
182
- }
183
-
184
- /** 查继承 base;未命中/未启用返回 undefined。 */
185
- lookup(provider: string, modelId: string): ModelBase | undefined {
186
- const entry = this.document?.[provider]?.models?.[modelId]
187
- if (entry === undefined) return undefined
188
- return toModelBase(entry)
189
- }
190
-
191
- /** 全部 provider id(UI extends 选择器用)。 */
192
- providerIds(): string[] {
193
- return Object.keys(this.document ?? {})
194
- }
195
-
196
- /** 某 provider 的模型 id 列表(UI extends 选择器用)。 */
197
- modelIds(provider: string): string[] {
198
- return Object.keys(this.document?.[provider]?.models ?? {})
199
- }
200
-
201
- status(): ModelsDevStatus {
202
- const providers = Object.keys(this.document ?? {})
203
- const models = providers.reduce(
204
- (total, p) => total + Object.keys(this.document?.[p]?.models ?? {}).length,
205
- 0,
206
- )
207
- return {
208
- fetchedAt: this.fetchedAt,
209
- providers: providers.length,
210
- models,
211
- error: this.lastError,
212
- }
213
- }
214
-
215
- private isStale(): boolean {
216
- if (this.fetchedAt === null) return true
217
- const ageMs = Date.now() - Date.parse(this.fetchedAt)
218
- return !Number.isFinite(ageMs) || ageMs > this.ttlHours * 3600_000
219
- }
220
-
221
- private loadCache(): void {
222
- try {
223
- const raw = JSON.parse(readFileSync(this.cacheFile, 'utf8')) as CacheFile
224
- if (!isDocument(raw.data) || typeof raw.fetchedAt !== 'string')
225
- throw new Error('缓存形状非法')
226
- this.document = raw.data
227
- this.fetchedAt = raw.fetchedAt
228
- } catch {
229
- // 无缓存或缓存损坏:静默留给刷新补齐
230
- }
231
- }
232
-
233
- private async doFetch(): Promise<void> {
234
- try {
235
- const response = await fetchJson(this.url, this.proxy, FETCH_TIMEOUT_MS)
236
- if (response.status < 200 || response.status >= 300) {
237
- throw new Error(`HTTP ${response.status}`)
238
- }
239
- const data: unknown = JSON.parse(response.body)
240
- if (!isDocument(data)) throw new Error('响应不是 models.dev 目录文档')
241
- this.document = data
242
- this.fetchedAt = new Date().toISOString()
243
- this.lastError = null
244
- this.persistCache(data)
245
- } catch (error) {
246
- this.lastError = error instanceof Error ? error.message : String(error)
247
- this.log(`models.dev 目录拉取失败(沿用缓存/仅内置目录):${this.lastError}`)
248
- }
249
- }
250
-
251
- private persistCache(data: ModelsDevDocument): void {
252
- try {
253
- mkdirSync(dirname(this.cacheFile), { recursive: true })
254
- const tmp = `${this.cacheFile}.tmp`
255
- writeFileSync(tmp, JSON.stringify({ fetchedAt: this.fetchedAt, data }), 'utf8')
256
- renameSync(tmp, this.cacheFile)
257
- } catch (error) {
258
- this.log(`models.dev 缓存写入失败:${error instanceof Error ? error.message : String(error)}`)
259
- }
260
- }
261
- }