@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.
- package/lib/client.js +1489 -636
- package/lib/index.d.ts +4 -8
- package/lib/index.js +943 -391
- package/package.json +35 -36
- package/src/catalog/browse.ts +297 -0
- package/src/catalog/builtin.ts +183 -35
- package/src/client/api.ts +96 -33
- package/src/client/card.tsx +180 -120
- package/src/client/client.ts +2 -2
- package/src/client/constants.ts +25 -5
- package/src/client/draft.ts +34 -13
- package/src/client/fields.tsx +18 -3
- package/src/client/i18n.ts +153 -49
- package/src/client/root-fields.tsx +64 -52
- package/src/client/route-fit.ts +117 -0
- package/src/client/styles.ts +40 -10
- package/src/client/views/catalog-browser.tsx +376 -0
- package/src/client/views/catalog-row.tsx +147 -0
- package/src/client/views/compat.tsx +31 -3
- package/src/client/views/models.tsx +189 -193
- package/src/client/views/provider-fields.tsx +2 -2
- package/src/client/views/providers.tsx +51 -11
- package/src/compat.ts +43 -19
- package/src/config-api.ts +65 -37
- package/src/config.ts +20 -28
- package/src/inherit.ts +15 -31
- package/src/kit-meta.ts +156 -0
- package/src/{compat-gates.ts → official-surface.ts} +60 -16
- package/src/profiles.ts +52 -13
- package/src/resolve-dsh.ts +291 -72
- package/src/service.ts +209 -90
- package/src/catalog/models-dev.ts +0 -261
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,
|
|
28
|
+
import { hasVolatileRefs, unwrapVolatile } from '@dsh-plus/shared'
|
|
24
29
|
|
|
25
|
-
import {
|
|
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
|
-
/**
|
|
44
|
-
kitInfo():
|
|
45
|
-
/**
|
|
46
|
-
|
|
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
|
-
/**
|
|
85
|
-
|
|
128
|
+
/** 解析运行期套件并落启动日志(来源/生效版本/协议/逐项降级诊断)。 */
|
|
129
|
+
async function loadRuntimeKit(
|
|
86
130
|
ctx: Context,
|
|
87
|
-
|
|
88
|
-
): Promise<
|
|
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(
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
139
|
+
const versionNotice = piAiVersionNotice(kit.versions.piAi)
|
|
140
|
+
if (versionNotice !== undefined) logger.warn(versionNotice)
|
|
141
|
+
return { kit, diagnostics }
|
|
142
|
+
}
|
|
111
143
|
|
|
112
|
-
|
|
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:
|
|
115
|
-
let memoizedDeepseek:
|
|
116
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
const deepseekRoutes = (): Map<string, ResolvedDeepseekRoute> => {
|
|
178
|
+
const deepseekRoutes = (): DeepseekMap => {
|
|
142
179
|
profiles()
|
|
143
180
|
return memoizedDeepseek ?? new Map()
|
|
144
181
|
}
|
|
145
|
-
|
|
182
|
+
return { profiles, deepseekRoutes }
|
|
183
|
+
}
|
|
146
184
|
|
|
147
|
-
|
|
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 与
|
|
152
|
-
// dsh 树 dev 布局(src/auth.ts
|
|
153
|
-
//
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
ctx
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
232
|
-
const facts = registrationFacts(
|
|
271
|
+
const resolved = profiles()
|
|
272
|
+
const facts = registrationFacts(resolved)
|
|
233
273
|
if (deepEqualJson(facts, registeredFacts)) return
|
|
234
|
-
const routes = [...
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
345
|
-
|
|
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
|
-
}
|