@dsh-plus/lifeboat 0.2.29 → 0.3.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.
@@ -1,355 +0,0 @@
1
- /**
2
- * LLM 应急翻译:默认模型路由的 provider 无已注册 adapter 时(典型场景:llm-pi
3
- * 被隔离/加载失败),把 settings.yaml 里 dsh-plus-llm-pi 的 provider 配置翻译为
4
- * 官方 llm-pi-ai 命名空间的原生格式(键加 -fb 后缀避开 DUPLICATE_ADAPTER 冲突),
5
- * 默认模型指到翻译路由;源 provider 恢复健康后按 journal 自动还原。
6
- *
7
- * 为什么读原始 settings.yaml 而不是 settings 服务:插件被禁用后其命名空间随之
8
- * 注销,服务层读不到——但数据仍在文件里(只读,不修改)。
9
- * 降级语义:翻译只覆盖官方物化的字段子集,dsh-plus 扩展的全量 compat 丢弃;
10
- * 协议推断是启发式(显式 api > 路由名 > 模型继承源 > openai-completions 兜底)。
11
- * @module lifeboat/fallback-llm
12
- */
13
- import { readFile } from 'node:fs/promises'
14
-
15
- import type { Context } from '@deepseek-ai/cordis'
16
- import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
17
- import type {} from '@deepseek-ai/dsh-llm'
18
- import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
19
- import { parseDocument } from 'yaml'
20
-
21
- import type { FallbackStateT } from './config.ts'
22
- import type { Alerter } from './notify.ts'
23
-
24
- // 0.1.2-alpha.2 起 settings 命名空间直接以字面量使用(编译期校验语法),
25
- // 不再需要 settingsNamespace() 品牌化。
26
- const NS_PI_AI = 'llm-pi-ai'
27
- const NS_AGENT_DEFAULT = 'agent-default-model'
28
- const RAW_NS_LLM_PI = 'dsh-plus-llm-pi'
29
-
30
- /** 翻译路由键后缀:与源路由错开,避免 llm-pi 恢复瞬间撞 DUPLICATE_ADAPTER。 */
31
- export const FALLBACK_SUFFIX = '-fb'
32
-
33
- /** 官方配置路径物化的 compat 子集(其余字段降级期丢弃)。 */
34
- const COMPAT_WHITELIST = ['thinkingFormat', 'supportsReasoningEffort'] as const
35
-
36
- /**
37
- * master 官方 llm-pi-ai 的 COMPAT_GATES(llm-pi-ai/src/catalog.ts):每个 compat
38
- * 字段按协议门控,写时经 assertOfferedCompatFields 拒绝不适配字段——thinkingFormat /
39
- * supportsReasoningEffort 仅 openai-completions 提供。翻译按推断 api 过滤,避免
40
- * 整段 compat 写入被官方校验拒绝。
41
- */
42
- const COMPAT_OFFER_APIS: Readonly<Record<string, ReadonlySet<string>>> = {
43
- thinkingFormat: new Set(['openai-completions']),
44
- supportsReasoningEffort: new Set(['openai-completions']),
45
- }
46
-
47
- /** 官方 reasoning 词表(ModelThinkingLevel 的保守子集),不在其中的值丢弃。 */
48
- const REASONING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
49
-
50
- /** dsh-plus-llm-pi provider 配置的松散视图(原始文件读取,未经 schema 解析)。 */
51
- export interface RawProvider {
52
- displayName?: string
53
- apiKeyEnv?: string
54
- baseURL?: string
55
- api?: string
56
- headers?: Record<string, string>
57
- reasoning?: string
58
- thinkingBudgets?: Record<string, unknown>
59
- defaultInput?: string[]
60
- compat?: Record<string, unknown>
61
- models?: RawModel[]
62
- }
63
-
64
- export interface RawModel {
65
- id: string
66
- name?: string
67
- extends?: string
68
- contextWindow?: number
69
- maxTokens?: number
70
- input?: string[]
71
- reasoningEfforts?: Record<string, string>
72
- }
73
-
74
- /** 官方 llm-pi-ai provider 档案的翻译目标形状(字段子集)。 */
75
- export interface TranslatedProvider {
76
- displayName?: string
77
- apiKeyEnv?: string
78
- baseURL?: string
79
- api: string
80
- headers?: Record<string, string>
81
- reasoning?: string
82
- thinkingBudgets?: Record<string, unknown>
83
- defaultInput?: string[]
84
- compat?: Record<string, unknown>
85
- models?: Record<string, unknown>[]
86
- }
87
-
88
- /** 模型继承源(extends 的 provider 前缀)→ 协议的已知映射。 */
89
- const EXTENDS_PROTOCOL: Record<string, string> = {
90
- anthropic: 'anthropic-messages',
91
- 'kimi-coding': 'anthropic-messages',
92
- deepseek: 'openai-completions',
93
- openai: 'openai-completions',
94
- }
95
-
96
- /** 路由名里的协议线索。 */
97
- const ROUTE_PROTOCOL: Record<string, string> = {
98
- chat: 'openai-completions',
99
- completions: 'openai-completions',
100
- response: 'openai-responses',
101
- responses: 'openai-responses',
102
- anthropic: 'anthropic-messages',
103
- claude: 'anthropic-messages',
104
- }
105
-
106
- /**
107
- * 推断路由协议:显式 api > 路由名线索 > 首个模型的继承源 > openai-completions。
108
- * 兜底选 openai-completions 因为多数网关提供 OpenAI 兼容面;推错时告警里注明。
109
- */
110
- export function inferApi(routeKey: string, provider: RawProvider): string {
111
- if (typeof provider.api === 'string' && provider.api.length > 0) return provider.api
112
- const routeHint = ROUTE_PROTOCOL[routeKey.toLowerCase()]
113
- if (routeHint !== undefined) return routeHint
114
- const firstExtends = provider.models?.find((m) => typeof m.extends === 'string')?.extends
115
- const source = firstExtends?.split('/')[0]
116
- if (source !== undefined && EXTENDS_PROTOCOL[source] !== undefined)
117
- return EXTENDS_PROTOCOL[source]
118
- return 'openai-completions'
119
- }
120
-
121
- /** 单个 provider 翻译(纯函数)。 */
122
- export function translateProvider(routeKey: string, raw: RawProvider): TranslatedProvider {
123
- const out: TranslatedProvider = {
124
- displayName: `${raw.displayName ?? routeKey} [fallback]`,
125
- api: inferApi(routeKey, raw),
126
- }
127
- if (raw.apiKeyEnv !== undefined) out.apiKeyEnv = raw.apiKeyEnv
128
- if (raw.baseURL !== undefined) out.baseURL = raw.baseURL
129
- if (raw.headers !== undefined) out.headers = raw.headers
130
- if (raw.reasoning !== undefined && REASONING_LEVELS.has(raw.reasoning))
131
- out.reasoning = raw.reasoning
132
- if (raw.thinkingBudgets !== undefined) out.thinkingBudgets = raw.thinkingBudgets
133
- if (Array.isArray(raw.defaultInput) && raw.defaultInput.length > 0)
134
- out.defaultInput = raw.defaultInput
135
- if (raw.compat !== undefined) {
136
- const api = inferApi(routeKey, raw)
137
- const compat: Record<string, unknown> = {}
138
- for (const key of COMPAT_WHITELIST) {
139
- const value = raw.compat[key]
140
- // 空值同样丢弃:官方写时拒绝 valueless 键(catalog.ts assertOfferedCompatFields)
141
- if (value === undefined || value === null) continue
142
- // 按推断 api 门控:仅写入该协议 offer 的字段,其余丢弃(官方写时拒绝不适配字段)
143
- if (!COMPAT_OFFER_APIS[key]?.has(api)) continue
144
- compat[key] = value
145
- }
146
- if (Object.keys(compat).length > 0) out.compat = compat
147
- }
148
- if (Array.isArray(raw.models) && raw.models.length > 0) {
149
- out.models = raw.models.map((m) => {
150
- const model: Record<string, unknown> = { id: m.id }
151
- if (m.name !== undefined) model.name = m.name
152
- if (m.contextWindow !== undefined) model.contextWindow = m.contextWindow
153
- if (m.maxTokens !== undefined) model.maxTokens = m.maxTokens
154
- if (Array.isArray(m.input) && m.input.length > 0) model.input = m.input
155
- return model
156
- })
157
- }
158
- return out
159
- }
160
-
161
- /** 整表翻译:键加 -fb 后缀(纯函数)。 */
162
- export function translateProviders(
163
- providers: Record<string, RawProvider>,
164
- ): Record<string, TranslatedProvider> {
165
- const out: Record<string, TranslatedProvider> = {}
166
- for (const [key, raw] of Object.entries(providers)) {
167
- out[`${key}${FALLBACK_SUFFIX}`] = translateProvider(key, raw)
168
- }
169
- return out
170
- }
171
-
172
- /** 读 settings.yaml 原始文档的指定段落(只读;文件/段落缺失返回 undefined)。 */
173
- export async function readRawSection(
174
- settingsFile: string,
175
- ns: string,
176
- ): Promise<Record<string, unknown> | undefined> {
177
- const text = await readFile(settingsFile, 'utf-8')
178
- const doc = parseDocument(text).toJS() as Record<string, unknown>
179
- const section = doc[ns]
180
- if (typeof section !== 'object' || section === null) return undefined
181
- return section as Record<string, unknown>
182
- }
183
-
184
- interface SettingsWrite {
185
- update(ns: string, patch: object): Promise<void>
186
- mutate(
187
- ns: string,
188
- ops: readonly {
189
- op: 'set' | 'unset'
190
- path: readonly string[]
191
- value?: unknown
192
- }[],
193
- ): Promise<void>
194
- }
195
-
196
- /** 写 settings 带一次冲突重试(用户同时改配置时不抢,放弃并告警)。 */
197
- async function writeWithRetry(write: () => Promise<void>): Promise<void> {
198
- try {
199
- await write()
200
- } catch (error) {
201
- if (!(error instanceof SettingsConflictError)) throw error
202
- await write()
203
- }
204
- }
205
-
206
- export interface FallbackDeps {
207
- journal: (kind: string, detail: string) => void
208
- alert: Alerter
209
- /** 持久化翻译状态(journal 命名空间内)。 */
210
- readState(): FallbackStateT | null
211
- writeState(state: FallbackStateT | null): Promise<void>
212
- }
213
-
214
- /** 启动宽限(ms):llm-pi 等兄弟插件的 adapter 注册晚于 lifeboat 就绪,
215
- * 启动竞态下立即评估会把"还没加载"误判为"缺席"——每次重启都误发
216
- * 「应急翻译 + 已还原」两条通知。宽限期内一律不评估,宽限结束自动评估
217
- * 一次,事件订阅覆盖后续变化;真故障(llm-pi 缺席)在宽限后照常告警。 */
218
- export const BOOT_GRACE_MS = 60_000
219
-
220
- /** 装配 LLM 应急翻译:启动宽限结束后及 settings/llm 变化时评估一次(并发合并)。 */
221
- export function installLlmFallback(
222
- ctx: Context,
223
- deps: FallbackDeps,
224
- graceMs: number = BOOT_GRACE_MS,
225
- ): void {
226
- const logger = ctx.logger('lifeboat')
227
- const settings = ctx.settings
228
- const writes = settings as unknown as SettingsWrite
229
- const settingsFile = dshHomePath('settings.yaml')
230
- let inFlight: Promise<void> | undefined
231
- const startedAt = Date.now()
232
- const withinGrace = (): boolean => Date.now() - startedAt < graceMs
233
-
234
- const listProviderIds = (): Set<string> => new Set(ctx.llm.listProviders().map((p) => p.id))
235
-
236
- async function activate(provider: string, model: string): Promise<void> {
237
- const section = await readRawSection(settingsFile, RAW_NS_LLM_PI).catch(() => undefined)
238
- const providers = section?.providers as Record<string, RawProvider> | undefined
239
- if (providers === undefined || providers[provider] === undefined) {
240
- deps.alert(
241
- '[DSH] 默认模型不可用且无配置可翻译',
242
- `默认模型的 provider "${provider}" 没有已注册 adapter,且 ${RAW_NS_LLM_PI} 配置中找不到对应条目,无法应急翻译。请手动修复。`,
243
- )
244
- return
245
- }
246
- const translated = translateProviders(providers)
247
- const fbProvider = `${provider}${FALLBACK_SUFFIX}`
248
- try {
249
- // 0.1.7 起写入校验经 internal/config waterfall(官方 llm-pi-ai 同款,
250
- // 替代 0.1.6 installSection validate),落盘前拒绝不适配的翻译结果;
251
- // update 无 expectedRevision 本就无条件写,不做冲突重试——失败一律分类为「翻译失败」。
252
- await writes.update(NS_PI_AI, { providers: translated })
253
- await writes.update(NS_AGENT_DEFAULT, { provider: fbProvider, model })
254
- } catch (error) {
255
- const message = error instanceof Error ? error.message : String(error)
256
- throw new Error(`翻译失败(官方 llm-pi-ai 拒绝写入翻译后的配置): ${message}`)
257
- }
258
- await deps.writeState({
259
- active: true,
260
- originalProvider: provider,
261
- originalModel: model,
262
- fallbackProvider: fbProvider,
263
- providers: Object.keys(translated),
264
- at: new Date().toISOString(),
265
- })
266
- deps.journal(
267
- 'llm-fallback',
268
- `默认模型切换到 ${fbProvider}/${model}(翻译 ${Object.keys(translated).length} 个 provider)`,
269
- )
270
- deps.alert(
271
- '[DSH] LLM 配置已应急翻译',
272
- `默认模型的 provider "${provider}" 不可用(llm-pi 缺席?),已把 ${RAW_NS_LLM_PI} 配置翻译为官方 llm-pi-ai 路由(-fb 后缀)并切换默认模型为 ${fbProvider}/${model}。源插件恢复后将自动还原。`,
273
- )
274
- }
275
-
276
- async function revert(state: FallbackStateT): Promise<void> {
277
- const ops = state.providers.map((key) => ({
278
- op: 'unset' as const,
279
- path: ['providers', key],
280
- }))
281
- await writeWithRetry(() => writes.mutate(NS_PI_AI, ops))
282
- await writeWithRetry(() =>
283
- writes.update(NS_AGENT_DEFAULT, {
284
- provider: state.originalProvider,
285
- model: state.originalModel,
286
- }),
287
- )
288
- await deps.writeState(null)
289
- deps.journal(
290
- 'llm-fallback-revert',
291
- `默认模型还原为 ${state.originalProvider}/${state.originalModel}`,
292
- )
293
- deps.alert(
294
- '[DSH] LLM 应急翻译已还原',
295
- `provider "${state.originalProvider}" 恢复健康,默认模型已还原,临时翻译路由已清除。`,
296
- )
297
- }
298
-
299
- async function evaluate(): Promise<void> {
300
- const state = deps.readState()
301
- const ids = listProviderIds()
302
- if (state?.active) {
303
- if (ids.has(state.originalProvider)) await revert(state)
304
- return
305
- }
306
- // 0.1.7 无 settings.get:describe() 直读本条目平面值(volatile 解包后视图)。
307
- const def = ctx.settings.describe().find((row) => row.ns === NS_AGENT_DEFAULT)?.value as
308
- | { provider?: string; model?: string }
309
- | undefined
310
- if (def?.provider === undefined || def.model === undefined) return
311
- if (ids.has(def.provider)) return
312
- await activate(def.provider, def.model)
313
- }
314
-
315
- const run = (): void => {
316
- if (withinGrace()) return
317
- if (inFlight !== undefined) return
318
- inFlight = evaluate()
319
- .catch((error: unknown) => {
320
- const message = error instanceof Error ? error.message : String(error)
321
- logger.warn(`llm fallback 评估失败: ${message}`)
322
- deps.journal('llm-fallback-error', message)
323
- })
324
- .finally(() => {
325
- inFlight = undefined
326
- })
327
- }
328
-
329
- // 首次评估延迟到启动宽限结束:lifeboat 首位加载时兄弟插件的 adapter 尚未
330
- // 注册(llm-pi 就绪可能远超 10s),提前评估会把"还没加载"误判为"缺席"
331
- // (每次重启都误发应急翻译 + 还原通知)。宽限结束评估一次,事件订阅
332
- // 覆盖后续变化;宽限内的 settings/llm 事件同样被时间闸跳过,由收尾评估兜底。
333
- const bootTimer = setTimeout(run, graceMs)
334
- ctx.effect(() => () => clearTimeout(bootTimer), 'lifeboat: fallback boot timer')
335
- // 走 events 的字符串重载而非 ctx.on:'ready' 不是平台声明的事件
336
- // (cordis 4.0.2 内置仅 internal/*,dsh 0.1.5-rc.1 全树无任何声明或派发点),
337
- // ctx.on 要求 keyof Events,只能靠 as never 硬塞。events.on(name: string|symbol)
338
- // 是平台给动态事件名的正统入口,运行期同一事件总线、同一 fiber 归属、随 fiber
339
- // 卸载自动移除(已实测)。注意:该监听目前**无生产者**,属遗留死监听,保留是
340
- // 为不动运行期行为,待人工确认后决定是否删除。
341
- ctx.events.on('ready', run)
342
- // 只响应与本判定相关的命名空间变化:journal 写自身命名空间,不过滤会自触发循环。
343
- const WATCHED_NS = new Set(['agent-default-model', 'llm-pi-ai', RAW_NS_LLM_PI])
344
- // settings/document-updated 由 @deepseek-ai/dsh-settings 声明(本文件已导入
345
- // 该包),0.1.7 起载荷为 (ns, revision);监听器按 unknown 收,保留原有
346
- // typeof 字符串防御。watched 过滤避免 journal 自写自触发循环。
347
- ctx.root.on('settings/document-updated', (ns: unknown) => {
348
- if (typeof ns === 'string' && WATCHED_NS.has(ns)) run()
349
- })
350
- // 事件名已核对:0.1.2-alpha.2 的 dsh-llm 保留 'llm/adapters-updated'
351
- // (packages/llm/llm/src/types.ts:23 module augmentation,index.ts:344 于每次
352
- // adapter 注册/注销提交点 dispatch);@deepseek-ai/dsh-llm 的类型导入已携带该
353
- // 声明,无需 as never。
354
- ctx.root.on('llm/adapters-updated', run)
355
- }