@dsh-plus/llm-pi 0.1.0

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 ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * 运行时主逻辑:注册/热更新/发现(逐点对齐官方 dsh-llm-pi-ai apply 的模式)。
3
+ *
4
+ * - profiles 回调按原始 config 对象 identity 备忘;配置变更经 settings 的
5
+ * setSource/onChange 传播,下一请求生效(adapter 快照按 profiles identity 失效);
6
+ * - route 集或注册时捕获的事实(displayName/retryPolicy)变化 → 原子的
7
+ * handle.replace 重注册;写入被校验拒绝时保留旧注册(官方同款护栏);
8
+ * - registerConfigurableProviders + registerModelDiscovery 让插件 route
9
+ * 正常出现在官方 Models 页与"拉取可用模型"动作里。
10
+ * @module llm-pi/service
11
+ */
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
14
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
15
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
16
+ import type { ResolvedPiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
17
+ import { deepEqualJson, installSettingsSection } from '@deepseek-ai/dsh-settings'
18
+
19
+ import { hasBuiltinProvider } from './catalog/builtin.ts'
20
+ import { ModelsDevSource } from './catalog/models-dev.ts'
21
+ import { Config, SETTINGS_NS, type LlmPiConfig } from './config.ts'
22
+ import { discoverModels } from './discovery.ts'
23
+ import { assertServiceable, buildProfiles } from './profiles.ts'
24
+ import { resolveDshKit, type DshKit } from './resolve-dsh.ts'
25
+
26
+ export interface LlmPiRuntime {
27
+ /** 当前生效配置(settings 用户层解析结果或 cordis 行级 config)。 */
28
+ currentConfig(): LlmPiConfig
29
+ /** 运行时套件来源(dsh-tree / vendored)与回退诊断。 */
30
+ kitInfo(): { source: string; diagnostics: string[] }
31
+ /** models.dev 兜底源(配置卡片读状态用)。 */
32
+ modelsDev: ModelsDevSource
33
+ kit: DshKit
34
+ }
35
+
36
+ /** 注册时捕获的事实表;变化才重注册(按 provider 排序,免序误报)。 */
37
+ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>) {
38
+ return [...profiles.entries()]
39
+ .map(([provider, profile]) => ({
40
+ provider,
41
+ displayName: profile.displayName,
42
+ retryPolicy: profile.retryPolicy,
43
+ }))
44
+ .sort((left, right) => left.provider.localeCompare(right.provider))
45
+ }
46
+
47
+ /** 凭据解析(逐行对齐官方 resolveApiKey):凭据服务优先,缺失时启动环境兜底。 */
48
+ function makeResolveApiKey(ctx: Context, kit: DshKit) {
49
+ return async (provider: string, profile: ResolvedPiAiProviderProfile): Promise<string | undefined> => {
50
+ const ref = profile.apiKeyEnv
51
+ if (ref === undefined) return undefined
52
+ const credentials = ctx.get('credentials')
53
+ const hit =
54
+ credentials !== undefined
55
+ ? (await credentials.resolve(ref as CredentialRef))?.value
56
+ : launchEnvironmentOf(ctx).get(ref as unknown as string)?.value
57
+ if (hit !== undefined && hit.length > 0) return kit.assertUsableApiKey(hit, 'llm-pi', ref as unknown as string)
58
+ throw new kit.LlmError(
59
+ `llm-pi: provider route "${provider}" 的凭据引用 ${String(ref)} 未解析到值——` +
60
+ '请经凭据服务(web Models 页)存放或导出环境变量;仅当该 provider 应使用 pi-ai 自有环境发现时才移除 apiKeyEnv',
61
+ 'MISSING_CREDENTIAL',
62
+ )
63
+ }
64
+ }
65
+
66
+ /** 启动插件运行时:解析套件、挂载注册/发现/settings 联动。 */
67
+ export async function startRuntime(ctx: Context, rawConfig: LlmPiConfig): Promise<LlmPiRuntime> {
68
+ const logger = ctx.logger('llm-pi')
69
+ // cordis 行级 config 可能未经 schema 解析(insert 行无 config 键时为原始空对象),
70
+ // 在此统一规范化,保证 enabled/默认值在纯组合层场景也成立。
71
+ const config = Config(rawConfig ?? {})
72
+ const { kit, diagnostics } = await resolveDshKit()
73
+ for (const line of diagnostics) logger.warn(line)
74
+ logger.info(`运行时套件来源:${kit.source}`)
75
+
76
+ const modelsDev = new ModelsDevSource(
77
+ dshHomePath('storages', 'dsh-plus-llm-pi', 'models-dev.json'),
78
+ config.catalogUrl,
79
+ config.catalogRefreshHours,
80
+ (message) => logger.warn(message),
81
+ config.catalogProxy ?? '',
82
+ )
83
+ void modelsDev.ensureLoaded()
84
+
85
+ let current: () => LlmPiConfig = () => config
86
+ let lastRaw: LlmPiConfig | undefined
87
+ let memoized: Map<string, ResolvedPiAiProviderProfile> | undefined
88
+ const deps = { kit, modelsDev }
89
+ /** 当前已解析 profiles,按原始 config identity 备忘(官方同款模式)。
90
+ * 运行期走 lenient:数据源漂移时降级/跳过并告警,而非抛错弄挂整个 route。 */
91
+ const profiles = (): Map<string, ResolvedPiAiProviderProfile> => {
92
+ const raw = current()
93
+ if (raw === lastRaw && memoized !== undefined) return memoized
94
+ const next = raw.enabled
95
+ ? buildProfiles(raw.providers, { ...deps, lenient: true, warn: (message) => logger.warn(message) })
96
+ : new Map()
97
+ lastRaw = raw
98
+ memoized = next
99
+ return next
100
+ }
101
+ profiles() // 行级 config 不可服务则启动即失败(官方同款 fail-fast)
102
+
103
+ const adapter = new kit.PiAiAdapter({
104
+ profiles,
105
+ resolveApiKey: makeResolveApiKey(ctx, kit),
106
+ resolveAttachments: () => ctx.get('attachments'),
107
+ })
108
+
109
+ const storedApiKey = async (provider: string | undefined): Promise<string | undefined> => {
110
+ if (provider === undefined) return undefined
111
+ const profile = profiles().get(provider)
112
+ if (profile === undefined) return undefined
113
+ return makeResolveApiKey(ctx, kit)(provider, profile)
114
+ }
115
+ ctx.llm.registerModelDiscovery(SETTINGS_NS, (request: Parameters<typeof discoverModels>[0]) =>
116
+ discoverModels(request, {
117
+ kit,
118
+ configProviders: () => current().providers ?? {},
119
+ storedApiKey,
120
+ }),
121
+ )
122
+
123
+ /**
124
+ * 注册 handle 组。正常路径单个 handle 整批注册/替换;整批注册遇
125
+ * DUPLICATE_ADAPTER(route 名与其他 adapter 冲突)时降级为逐个注册,
126
+ * 跳过冲突 route——启动不再 fail-loud,其余 route 照常服务。
127
+ */
128
+ interface RegistrationGroup {
129
+ routes: string[]
130
+ handle: { replace(routes: string[]): void }
131
+ }
132
+ let registrations: RegistrationGroup[] | undefined
133
+ let registeredFacts: unknown
134
+
135
+ const registerGroup = (routes: string[], fallback: (error: unknown) => void): RegistrationGroup[] => {
136
+ try {
137
+ const handle = ctx.llm.registerAdapter(routes, adapter as never)
138
+ return [{ routes, handle }]
139
+ } catch (error) {
140
+ fallback(error)
141
+ const groups: RegistrationGroup[] = []
142
+ for (const route of routes) {
143
+ try {
144
+ const handle = ctx.llm.registerAdapter([route], adapter as never)
145
+ groups.push({ routes: [route], handle })
146
+ } catch (routeError) {
147
+ logger.error(`llm-pi: route "${route}" 注册失败(可能与其他 adapter 重名),该 route 不可用`)
148
+ logger.error(routeError)
149
+ }
150
+ }
151
+ return groups
152
+ }
153
+ }
154
+
155
+ const ensureRegistration = (): void => {
156
+ const current2 = profiles()
157
+ const facts = registrationFacts(current2)
158
+ if (deepEqualJson(facts, registeredFacts)) return
159
+ const routes = [...current2.keys()]
160
+ if (registrations === undefined) {
161
+ if (routes.length === 0) {
162
+ registeredFacts = facts
163
+ return
164
+ }
165
+ registrations = registerGroup(routes, (error) => {
166
+ logger.warn('llm-pi: 整批注册失败(可能 route 名冲突),降级为逐个 route 注册')
167
+ logger.warn(error)
168
+ })
169
+ } else {
170
+ try {
171
+ registrations[0]!.handle.replace(routes)
172
+ for (let i = 1; i < registrations.length; i += 1) registrations[i]!.handle.replace([])
173
+ registrations = [{ routes, handle: registrations[0]!.handle }]
174
+ } catch (error) {
175
+ // 原子 replace 被拒(含冲突):保留此前注册(官方同款护栏)
176
+ logger.error('llm-pi: 更新被拒,保留此前注册的 route')
177
+ logger.error(error)
178
+ }
179
+ }
180
+ registeredFacts = facts
181
+ }
182
+
183
+ let directory: { replace: (entries: unknown[]) => void } | undefined
184
+ let directoryFacts: unknown
185
+ const ensureDirectory = (): void => {
186
+ const entries = [...profiles().entries()].map(([provider, profile]) => ({
187
+ provider,
188
+ displayName: profile.displayName,
189
+ settingsNs: SETTINGS_NS,
190
+ settingsPath: ['providers', provider],
191
+ declared: !hasBuiltinProvider(kit, provider),
192
+ }))
193
+ if (deepEqualJson(entries, directoryFacts)) return
194
+ if (entries.length === 0) {
195
+ // 空目录不可注册(INVALID_DIRECTORY);等 settings 用户层供数后在 onChange 注册
196
+ directoryFacts = entries
197
+ return
198
+ }
199
+ if (directory === undefined) directory = ctx.llm.registerConfigurableProviders(entries as never)
200
+ else directory.replace(entries)
201
+ directoryFacts = entries
202
+ }
203
+
204
+ ensureRegistration()
205
+ ensureDirectory()
206
+
207
+ let settingsBound = false
208
+ installSettingsSection(ctx, SETTINGS_NS, Config, config, {
209
+ validate: (cfg: LlmPiConfig) => assertServiceable(cfg, deps),
210
+ setSource: (source: () => LlmPiConfig) => {
211
+ settingsBound = true
212
+ current = source
213
+ },
214
+ onChange: () => {
215
+ try {
216
+ ensureRegistration()
217
+ } catch (error) {
218
+ logger.error('llm-pi: 更新被拒,保留此前注册的 route')
219
+ logger.error(error)
220
+ }
221
+ try {
222
+ ensureDirectory()
223
+ } catch (error) {
224
+ logger.error('llm-pi: 更新被拒,保留此前的 configurable-provider 目录')
225
+ logger.error(error)
226
+ }
227
+ const cfg = current()
228
+ modelsDev.reconfigure(cfg.catalogUrl, cfg.catalogRefreshHours, cfg.catalogProxy ?? '')
229
+ },
230
+ })
231
+
232
+ return {
233
+ currentConfig: () => current(),
234
+ kitInfo: () => ({ source: kit.source, diagnostics }),
235
+ modelsDev,
236
+ kit,
237
+ }
238
+ }