@dsh-plus/llm-pi 0.1.44 → 0.2.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/client/api.ts CHANGED
@@ -18,6 +18,14 @@ export interface WireKitInfo {
18
18
  protocolSource: string
19
19
  catalog: { providers: number; models: number; generatedAt?: number }
20
20
  compatSource: string
21
+ /** 官方应急副本状态(服务端 official-copy-writer 的 OfficialCopyStatus 镜像)。 */
22
+ officialCopy: {
23
+ path: string
24
+ updatedAt?: number
25
+ routes: number
26
+ warnings: string[]
27
+ error?: string
28
+ }
21
29
  diagnostics: string[]
22
30
  }
23
31
 
@@ -23,6 +23,10 @@ const ownZh = {
23
23
  runtimeCatalog: '内置模型目录',
24
24
  runtimeProtocols: '可服务协议',
25
25
  runtimeCompat: 'compat 门控表',
26
+ runtimeOfficialCopy: '应急副本',
27
+ officialCopyPending: '尚未生成',
28
+ officialCopyFailed: '生成失败:',
29
+ officialCopyRoutes: '个 route',
26
30
  runtimeDiagnostics: '诊断',
27
31
  runtimeVersionUnknown: '未知',
28
32
  runtimeCatalogUnit: '个 provider /',
@@ -160,6 +164,10 @@ const ownEn = {
160
164
  runtimeCatalog: 'Built-in catalog',
161
165
  runtimeProtocols: 'Servable protocols',
162
166
  runtimeCompat: 'compat gates',
167
+ runtimeOfficialCopy: 'Emergency copy',
168
+ officialCopyPending: 'not generated yet',
169
+ officialCopyFailed: 'generation failed: ',
170
+ officialCopyRoutes: 'routes',
163
171
  runtimeDiagnostics: 'Diagnostics',
164
172
  runtimeVersionUnknown: 'unknown',
165
173
  runtimeCatalogUnit: 'providers /',
@@ -36,6 +36,14 @@ function sourceLabel(t: Translate, source: string): string {
36
36
  return source === 'official' ? t('runtimeDerived') : t('runtimeFallback')
37
37
  }
38
38
 
39
+ /** 应急副本状态行:路径 · route 数 · 生成时间;未生成/失败显式提示。 */
40
+ function officialCopyText(kit: WireKitInfo, t: Translate): string {
41
+ const copy = kit.officialCopy
42
+ if (copy.error !== undefined) return `${t('officialCopyFailed')}${copy.error}`
43
+ if (copy.updatedAt === undefined) return t('officialCopyPending')
44
+ return `${copy.path} · ${copy.routes} ${t('officialCopyRoutes')} · ${formatTime(copy.updatedAt, t('runtimeVersionUnknown'))}`
45
+ }
46
+
39
47
  export interface RootFieldsProps {
40
48
  t: Translate
41
49
  draft: Draft
@@ -80,6 +88,9 @@ export function RootFields(props: RootFieldsProps): ReactElement {
80
88
  <p className="lpc-statusRow">
81
89
  {`${t('runtimeCompat')}:${sourceLabel(t, kit.compatSource)}`}
82
90
  </p>
91
+ <p className="lpc-statusRow">
92
+ {`${t('runtimeOfficialCopy')}:${officialCopyText(kit, t)}`}
93
+ </p>
83
94
  {kit.versionNotice !== undefined ? (
84
95
  <p className="lpc-invalid">{`${t('runtimeNotice')}:${kit.versionNotice}`}</p>
85
96
  ) : null}
package/src/index.ts CHANGED
@@ -11,7 +11,10 @@
11
11
  *
12
12
  * 接线约束:inject 须含 credentials(auth-inline/deepseek-routes 经
13
13
  * ctx.get('credentials') 读 seam;凭据解析为请求时惰性调用,硬声明保证
14
- * 时序,消除启动早窗口的一次性 MISSING_CREDENTIAL)。
14
+ * 时序,消除启动早窗口的一次性 MISSING_CREDENTIAL)与 settings
15
+ * (应急副本手写条目来源:readManualProviders 经 ctx.settings.describe
16
+ * 读官方 llm-pi-ai 生效视图;漏声明即启动期 "cannot get property without
17
+ * inject" → 插件树加载失败)。
15
18
  * @module @dsh-plus/llm-pi
16
19
  */
17
20
  import type { Context } from '@deepseek-ai/cordis'
@@ -22,7 +25,7 @@ import { startRuntime } from './service.ts'
22
25
 
23
26
  export const name = 'dsh-plus-llm-pi'
24
27
 
25
- export const inject = ['credentials', 'llm'] as const
28
+ export const inject = ['credentials', 'llm', 'settings'] as const
26
29
 
27
30
  export type {
28
31
  LlmPiConfig,
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 官方应急副本的生成编排:装配(原始配置 → 物化 → 翻译)→ 逐 route 官方校验
3
+ * → 备忘跳过 / 原子落盘 → 状态供配置卡与 lifeboat 面板消费。
4
+ *
5
+ * 触发时机:插件启动、配置热更新(loader/volatile-update)、adapter 注册变化
6
+ * (llm/adapters-updated,官方 llm-pi-ai 晚注册时手写条目在此补入)。无定时器、
7
+ * 无 watcher——平时零开销;写失败只降级为状态与日志,绝不影响路由注册。
8
+ *
9
+ * 逐 route 官方校验是「副本必然官方可识别」的执行点:官方 Config schema 解析
10
+ * (非法协议/越界取值在此抛错)+ 官方 resolveProfiles 可服务性链;任一不过即
11
+ * 剔除该 route 并记 warning——写不进官方的东西绝不落盘。
12
+ * @module llm-pi/official-copy-writer
13
+ */
14
+ import { stat } from 'node:fs/promises'
15
+ import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
16
+ import { atomicWriteFile, isVolatileRef } from '@dsh-plus/shared'
17
+
18
+ import type { LlmPiConfig } from './config.ts'
19
+ import {
20
+ buildOfficialCopy,
21
+ deriveOfficialRouteFields,
22
+ FALLBACK_ROUTE_FIELDS,
23
+ OFFICIAL_COPY_PATH,
24
+ type OfficialCopy,
25
+ renderOfficialCopy,
26
+ } from './official-copy.ts'
27
+ import { normalizeOfficialRoutes } from './profiles.ts'
28
+ import { buildDeepseekRoutes } from './profiles-deepseek.ts'
29
+ import type { DshKit } from './resolve-dsh.ts'
30
+
31
+ /** 官方 llm-pi-ai 的 settings 命名空间(与插件 NS 同字面量,官方 0.1.x 起稳定)。 */
32
+ const NS_OFFICIAL_PI_AI = 'llm-pi-ai'
33
+
34
+ /** 副本状态(配置卡状态行与诊断的 JSON 面;updatedAt 缺省 = 尚未生成)。 */
35
+ export interface OfficialCopyStatus {
36
+ path: string
37
+ updatedAt?: number
38
+ routes: number
39
+ warnings: string[]
40
+ error?: string
41
+ }
42
+
43
+ /** 手写官方条目读取结果:providers 缺省 + warning 说明降级原因。 */
44
+ export interface ManualSourceResult {
45
+ providers?: Record<string, unknown>
46
+ warning?: string
47
+ }
48
+
49
+ /** settings 服务窄面(describe 的描述符子集;测试可注入固定值)。 */
50
+ export interface SettingsDescribeLike {
51
+ describe(): Array<{ ns: string; value?: unknown }>
52
+ }
53
+
54
+ /**
55
+ * 手写官方条目来源:settings describe 的 llm-pi-ai 生效视图(composition base
56
+ * = profile patch 行 + settings 用户层,schema 已解析、已过官方 validate)——
57
+ * 官方 seam,无需自行解析 patch 文件。ns 未注册/读取失败时降级为「仅本插件
58
+ * 改写条目」并附 warning,不阻断生成。
59
+ */
60
+ export function readManualProviders(settings: SettingsDescribeLike): ManualSourceResult {
61
+ try {
62
+ const row = settings.describe().find((entry) => entry.ns === NS_OFFICIAL_PI_AI)
63
+ if (row === undefined) {
64
+ return { warning: '官方 llm-pi-ai 未注册(插件缺席),副本仅含本插件改写条目' }
65
+ }
66
+ const raw = (row.value as { providers?: unknown } | undefined)?.providers
67
+ const providers = isVolatileRef(raw) ? raw.get() : raw
68
+ if (providers === null || typeof providers !== 'object') return {}
69
+ return { providers: providers as Record<string, unknown> }
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error)
72
+ return { warning: `读取官方 llm-pi-ai 生效配置失败(${message}),副本仅含本插件改写条目` }
73
+ }
74
+ }
75
+
76
+ export interface OfficialCopyWriterDeps {
77
+ kit: DshKit
78
+ /** 当前生效的本插件配置(volatile 已解包的平面快照)。 */
79
+ current: () => LlmPiConfig
80
+ /** 手写官方条目读取(生产接 readManualProviders(ctx.settings))。 */
81
+ readManual: () => ManualSourceResult
82
+ /** 来源 profile 名(头注释应用指引用)。 */
83
+ profile: string | undefined
84
+ /** 落盘路径(缺省 .dsh 根的 OFFICIAL_COPY_PATH;测试注入临时路径)。 */
85
+ path?: string
86
+ logger: { warn(message: unknown): void }
87
+ }
88
+
89
+ export interface OfficialCopyWriter {
90
+ /** 生成(或确认最新)副本:串行化执行,失败降级为状态,绝不抛出。 */
91
+ ensureOfficialCopy(): Promise<void>
92
+ /** 最近一次生成的状态。 */
93
+ status(): OfficialCopyStatus
94
+ }
95
+
96
+ /** 文件是否已存在(内容备忘跳过后自愈外部删除)。 */
97
+ async function fileExists(path: string): Promise<boolean> {
98
+ try {
99
+ return (await stat(path)).isFile()
100
+ } catch {
101
+ return false
102
+ }
103
+ }
104
+
105
+ /** 组装 + 翻译:返回含全部 warning 的副本内容(尚未官方校验)。 */
106
+ function assemble(deps: OfficialCopyWriterDeps, warnings: string[]): OfficialCopy {
107
+ const routeFields = deriveOfficialRouteFields(deps.kit.officialConfig)
108
+ if (routeFields === undefined) {
109
+ warnings.push('官方 Config schema 布局不可识别,route 字段回退内置键集')
110
+ }
111
+ const providers = deps.current().providers ?? {}
112
+ const normalizedPi = normalizeOfficialRoutes(providers, {
113
+ kit: deps.kit,
114
+ lenient: true,
115
+ warn: (message) => warnings.push(message),
116
+ })
117
+ const deepseekResolved = buildDeepseekRoutes(providers, {
118
+ kit: deps.kit,
119
+ lenient: true,
120
+ warn: (message) => warnings.push(message),
121
+ })
122
+ const manual = deps.readManual()
123
+ if (manual.warning !== undefined) warnings.push(manual.warning)
124
+ const copy = buildOfficialCopy({
125
+ providers,
126
+ normalizedPi,
127
+ deepseekResolved,
128
+ manualProviders: manual.providers,
129
+ routeFields: routeFields ?? [...FALLBACK_ROUTE_FIELDS],
130
+ modelFields: deps.kit.officialModelFields,
131
+ })
132
+ warnings.push(...copy.warnings)
133
+ return { providers: copy.providers, warnings }
134
+ }
135
+
136
+ /** 逐 route 官方校验:schema 解析 + 可服务性链;不过即剔除并记 warning。 */
137
+ function acceptOfficial(
138
+ deps: OfficialCopyWriterDeps,
139
+ copy: OfficialCopy,
140
+ warnings: string[],
141
+ ): Record<string, unknown> {
142
+ const accepted: Record<string, unknown> = {}
143
+ for (const [route, profile] of Object.entries(copy.providers)) {
144
+ try {
145
+ deps.kit.officialConfig({ providers: { [route]: profile } })
146
+ deps.kit.resolveProfiles({ [route]: profile as PiAiProviderProfile })
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error)
149
+ warnings.push(`route "${route}" 未通过官方校验,已从副本剔除:${message}`)
150
+ continue
151
+ }
152
+ accepted[route] = profile
153
+ }
154
+ return accepted
155
+ }
156
+
157
+ /** 生成态:内容备忘(跳过同内容重写)与最近生成时间(mtime 语义)。 */
158
+ interface CopyState {
159
+ lastContent?: string
160
+ updatedAt?: number
161
+ }
162
+
163
+ /** 落盘:内容未变且文件仍在则跳过(并自愈外部删除);返回最近生成时间。 */
164
+ async function persist(path: string, content: string, state: CopyState): Promise<number> {
165
+ if (content === state.lastContent && (await fileExists(path)))
166
+ return state.updatedAt ?? Date.now()
167
+ await atomicWriteFile(path, content)
168
+ state.lastContent = content
169
+ state.updatedAt = Date.now()
170
+ return state.updatedAt
171
+ }
172
+
173
+ /** 生成一次副本(错误上抛,由调用方降级为 status.error)。 */
174
+ async function runOnce(
175
+ deps: OfficialCopyWriterDeps,
176
+ path: string,
177
+ state: CopyState,
178
+ ): Promise<OfficialCopyStatus> {
179
+ const warnings: string[] = []
180
+ const assembled = assemble(deps, warnings)
181
+ const providers = acceptOfficial(deps, assembled, warnings)
182
+ const content = renderOfficialCopy({ providers, warnings }, { profile: deps.profile })
183
+ const updatedAt = await persist(path, content, state)
184
+ return { path, updatedAt, routes: Object.keys(providers).length, warnings }
185
+ }
186
+
187
+ /** 装配写入器:启动/热更新/adapter 事件驱动,串行执行(promise 链)。 */
188
+ export function createOfficialCopyWriter(deps: OfficialCopyWriterDeps): OfficialCopyWriter {
189
+ const path = deps.path ?? OFFICIAL_COPY_PATH
190
+ const state: CopyState = {}
191
+ let status: OfficialCopyStatus = { path, routes: 0, warnings: [] }
192
+ let chain: Promise<void> = Promise.resolve()
193
+ const ensureOfficialCopy = (): Promise<void> => {
194
+ chain = chain.then(async () => {
195
+ try {
196
+ status = await runOnce(deps, path, state)
197
+ } catch (error) {
198
+ // 落盘/装配失败:磁盘上仍是上一次成功的副本,保留其状态并标注错误。
199
+ const message = error instanceof Error ? error.message : String(error)
200
+ deps.logger.warn(`llm-pi: 应急副本生成失败:${message}`)
201
+ status = { ...status, error: message }
202
+ }
203
+ })
204
+ return chain
205
+ }
206
+ return { ensureOfficialCopy, status: () => status }
207
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * 官方应急副本的翻译与渲染(纯函数,零 IO)。
3
+ *
4
+ * 产出一份「官方可识别」的 profile patch 覆盖层:把 dsh-plus-llm-pi 的 route
5
+ * 配置改写为官方 `llm-pi-ai` 的 Config 形状,另附一行禁用 dsh-plus-llm-pi 的
6
+ * 覆盖(应用即切换,同名 route 不撞 DUPLICATE_ADAPTER)。写盘、逐 route 官方
7
+ * 校验与状态管理见 official-copy-writer.ts。
8
+ *
9
+ * 三条翻译底线(对旧「出错自动改写」失效教训的根治):
10
+ * 1. **只产出官方 schema 声明的字段**:route 键集从官方 Config schema 现场推导
11
+ * (失败回退内置键集),模型条目按 kit.officialModelFields 过滤——官方拒收
12
+ * 的字段根本不出现在副本里;
13
+ * 2. **compat 只留 offer**:withhold 与未知键按生效门控表丢弃(目录继承值可能
14
+ * 携带 withhold 键,写时会被官方拒绝);
15
+ * 3. **继承全物化**:官方无 extends——模型条目来自 normalizeRoute 的全显式物化
16
+ * 产物(单协议、单端点约束由该链保证,无需拆分 route)。
17
+ *
18
+ * deepseek 路由改写为 openai-completions(中继网关本就是 OpenAI 兼容面):
19
+ * 文件通道与思考配置不迁移(官方 llm-pi-ai 无对应字段/无逐模型 reasoningEfforts),
20
+ * 以 warning 明示,绝不静默丢弃。
21
+ * @module llm-pi/official-copy
22
+ */
23
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
24
+ import type { DeepSeekCatalogModel } from '@deepseek-ai/dsh-llm-deepseek'
25
+ import type { PiAiModelProfile, PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
26
+ import { stringify } from 'yaml'
27
+
28
+ import { compatDispositionOf } from './compat.ts'
29
+ import type { ProviderProfileConfig } from './config.ts'
30
+ import type { SchemaNode } from './official-surface.ts'
31
+ import { isDraftRoute } from './profiles.ts'
32
+ import type { ResolvedDeepseekRoute } from './profiles-deepseek.ts'
33
+ import type { OfficialConfigSchema } from './resolve-dsh.ts'
34
+
35
+ /** 副本文件在 Harness home(.dsh)根目录的落点;lifeboat 面板按同一路径消费(文件契约)。 */
36
+ export const OFFICIAL_COPY_PATH = dshHomePath('llm-pi.official-patch.yaml')
37
+
38
+ /** deepseek 路由改写所用线协议:中继网关对外提供 OpenAI 兼容 chat completions 面。 */
39
+ export const DEEPSEEK_COPY_API = 'openai-completions'
40
+
41
+ /**
42
+ * 官方 Config schema 布局不可识别时的 route 字段兜底(官方 PiAiProviderProfile
43
+ * 最后已知键集)。与官方新增字段的失配只表现为「新字段不进副本」,不弄挂生成。
44
+ */
45
+ export const FALLBACK_ROUTE_FIELDS = [
46
+ 'apiKeyEnv',
47
+ 'displayName',
48
+ 'api',
49
+ 'baseURL',
50
+ 'models',
51
+ 'modelOverrides',
52
+ 'compat',
53
+ 'defaultContextWindow',
54
+ 'defaultMaxTokens',
55
+ 'defaultInput',
56
+ 'headers',
57
+ 'reasoning',
58
+ 'thinkingBudgets',
59
+ 'cacheRetention',
60
+ 'transport',
61
+ 'timeoutMs',
62
+ 'websocketConnectTimeoutMs',
63
+ 'streamIdleTimeoutMs',
64
+ 'maxRequestImageBytes',
65
+ 'requestImagePixelBudget',
66
+ 'requestImageMaxBytes',
67
+ 'retryPolicy',
68
+ ] as const
69
+
70
+ /** deepseek 专有且官方 llm-pi-ai 无对应字段的 raw 键(出现即记 warning)。 */
71
+ const DEEPSEEK_UNMIGRATED_FIELDS = [
72
+ 'thinking',
73
+ 'reasoningEffort',
74
+ 'maxRequestFilesBytes',
75
+ 'filesApiTimeoutMs',
76
+ 'fileExpiresAfterSeconds',
77
+ 'fileRefreshMarginSeconds',
78
+ 'maxImagesPerRequest',
79
+ 'imageOffloadByteQuantum',
80
+ 'inlineImageOffloadByteQuantum',
81
+ 'imageOffloadCountQuantum',
82
+ ] as const
83
+
84
+ /**
85
+ * 从官方 Config schema 推导 route 字段键集(`providers.*` 的 dict 键);
86
+ * 布局不可识别/键集为空返回 undefined,调用方回退内置键集并告警。
87
+ */
88
+ export function deriveOfficialRouteFields(config: OfficialConfigSchema): string[] | undefined {
89
+ const fields = (config as unknown as SchemaNode).dict?.['providers']?.inner?.dict
90
+ if (fields === undefined) return undefined
91
+ const keys = Object.keys(fields)
92
+ return keys.length > 0 ? keys : undefined
93
+ }
94
+
95
+ /** compat 过滤:只保留该协议 offer 的字段(withhold/未知键丢弃);无保留项返回 undefined。 */
96
+ function offerCompat(
97
+ api: string,
98
+ compat: Record<string, unknown> | undefined,
99
+ ): Record<string, unknown> | undefined {
100
+ if (compat === undefined || typeof compat !== 'object') return undefined
101
+ const out: Record<string, unknown> = {}
102
+ for (const [field, value] of Object.entries(compat)) {
103
+ if (value === undefined || value === null) continue
104
+ if (compatDispositionOf(api, field) === 'offer') out[field] = value
105
+ }
106
+ return Object.keys(out).length > 0 ? out : undefined
107
+ }
108
+
109
+ /** 单个模型条目 → 官方键集内的条目(compat 另过 offer 门控)。 */
110
+ function filterModel(
111
+ api: string,
112
+ model: PiAiModelProfile,
113
+ modelFields: readonly string[],
114
+ ): Record<string, unknown> {
115
+ const source = model as unknown as Record<string, unknown>
116
+ const out: Record<string, unknown> = {}
117
+ for (const key of modelFields) {
118
+ const value = source[key]
119
+ if (value === undefined) continue
120
+ if (key === 'compat') {
121
+ const compat = offerCompat(api, value as Record<string, unknown>)
122
+ if (compat !== undefined) out.compat = compat
123
+ continue
124
+ }
125
+ out[key] = value
126
+ }
127
+ return out
128
+ }
129
+
130
+ /** 归一化 profile → 官方 route 字段内的条目(models 逐条过滤)。 */
131
+ function filterRoute(
132
+ profile: PiAiProviderProfile,
133
+ routeFields: readonly string[],
134
+ modelFields: readonly string[],
135
+ ): Record<string, unknown> {
136
+ const source = profile as unknown as Record<string, unknown>
137
+ const api = typeof source['api'] === 'string' ? source['api'] : ''
138
+ const out: Record<string, unknown> = {}
139
+ for (const key of routeFields) {
140
+ const value = source[key]
141
+ if (value === undefined) continue
142
+ if (key === 'models') {
143
+ const models = Array.isArray(value) ? (value as PiAiModelProfile[]) : []
144
+ out.models = models.map((model) => filterModel(api, model, modelFields))
145
+ continue
146
+ }
147
+ out[key] = value
148
+ }
149
+ return out
150
+ }
151
+
152
+ /** deepseek 单模型 → 官方条目(inputModalities 改名 input;图像预算不迁移)。 */
153
+ function deepseekModelEntry(model: DeepSeekCatalogModel): Record<string, unknown> {
154
+ const out: Record<string, unknown> = { id: model.id }
155
+ if (model.name !== undefined) out.name = model.name
156
+ if (model.contextWindow !== undefined) out.contextWindow = model.contextWindow
157
+ if (model.maxTokens !== undefined) out.maxTokens = model.maxTokens
158
+ if (model.inputModalities !== undefined) out.input = [...model.inputModalities]
159
+ return out
160
+ }
161
+
162
+ interface DeepseekCopyResult {
163
+ profile: Record<string, unknown>
164
+ /** 未迁移的字段名(出现即入副本头 warning,绝不静默丢弃)。 */
165
+ unmirrored: string[]
166
+ }
167
+
168
+ /**
169
+ * deepseek 路由 → 官方 llm-pi-ai profile:连接事实取 resolveAdapterOptions 的
170
+ * 校验产物(端点/凭据引用/限额),模型取官方目录物化结果。思考配置与文件通道
171
+ * 字段不迁移(llm-pi-ai 无对应语义),逐项列入 unmirrored。
172
+ */
173
+ function deepseekCopyProfile(
174
+ raw: ProviderProfileConfig,
175
+ resolved: ResolvedDeepseekRoute,
176
+ ): DeepseekCopyResult {
177
+ const connection = resolved.connection
178
+ const unmirrored: string[] = DEEPSEEK_UNMIGRATED_FIELDS.filter((key) => raw[key] !== undefined)
179
+ if (raw.models?.some((m) => m.imagePixelBudget !== undefined || m.imageMaxBytes !== undefined))
180
+ unmirrored.push('模型级 imagePixelBudget/imageMaxBytes')
181
+ const profile: Record<string, unknown> = {
182
+ displayName: resolved.displayName,
183
+ api: DEEPSEEK_COPY_API,
184
+ baseURL: connection.baseURL,
185
+ apiKeyEnv: raw.apiKeyEnv,
186
+ defaultContextWindow: connection.defaultContextWindow,
187
+ defaultMaxTokens: connection.maxTokens,
188
+ streamIdleTimeoutMs: connection.streamIdleTimeoutMs,
189
+ models: connection.models.map(deepseekModelEntry),
190
+ }
191
+ if (raw.retryPolicy !== undefined) profile.retryPolicy = raw.retryPolicy
192
+ // 语义对应:deepseek 的 base64 降级后载荷上限 ↔ llm-pi-ai 的单请求 base64 上限。
193
+ if (raw.maxInlineRequestImageBytes !== undefined) {
194
+ profile.maxRequestImageBytes = connection.maxInlineRequestImageBytes
195
+ }
196
+ return { profile, unmirrored }
197
+ }
198
+
199
+ /** 翻译输入:运行期物化产物 + 原始配置 + 官方生效的手写条目。 */
200
+ export interface OfficialCopyInput {
201
+ /** 本插件当前原始配置(不受 enabled 影响——副本是配置层事实)。 */
202
+ providers: Record<string, ProviderProfileConfig>
203
+ /** normalizeOfficialRoutes 产物:pi route 的官方形状物化结果。 */
204
+ normalizedPi: ReadonlyMap<string, PiAiProviderProfile>
205
+ /** buildDeepseekRoutes 产物:deepseek route 的连接事实。 */
206
+ deepseekResolved: ReadonlyMap<string, ResolvedDeepseekRoute>
207
+ /** 官方 llm-pi-ai 生效 providers(settings describe 视图,含 profile 手写条目)。 */
208
+ manualProviders: Record<string, unknown> | undefined
209
+ /** 官方 route 字段键集(deriveOfficialRouteFields / FALLBACK_ROUTE_FIELDS)。 */
210
+ routeFields: readonly string[]
211
+ /** 官方模型条目字段键集(kit.officialModelFields,现场推导)。 */
212
+ modelFields: readonly string[]
213
+ }
214
+
215
+ export interface OfficialCopy {
216
+ /** route 键 → 官方 profile(已过滤;手写条目覆盖同名 key)。空 = 空补丁。 */
217
+ providers: Record<string, unknown>
218
+ /** 生成期警告(渲染进副本头注释,也进配置卡诊断)。 */
219
+ warnings: string[]
220
+ }
221
+
222
+ /** 翻译整份配置为官方副本内容(纯函数;坏 route 的跳过告警由上游 warn 通道汇集)。 */
223
+ export function buildOfficialCopy(input: OfficialCopyInput): OfficialCopy {
224
+ const warnings: string[] = []
225
+ const providers: Record<string, unknown> = {}
226
+ for (const [route, raw] of Object.entries(input.providers)) {
227
+ if ((raw.adapter ?? 'pi') === 'deepseek') {
228
+ const resolved = input.deepseekResolved.get(route)
229
+ if (resolved === undefined) continue // 物化失败:buildDeepseekRoutes 已告警
230
+ const { profile, unmirrored } = deepseekCopyProfile(raw, resolved)
231
+ providers[route] = profile
232
+ if (unmirrored.length > 0) {
233
+ warnings.push(
234
+ `deepseek route "${route}" 的字段未迁移到 llm-pi-ai:${unmirrored.join('、')}`,
235
+ )
236
+ }
237
+ continue
238
+ }
239
+ const normalized = input.normalizedPi.get(route)
240
+ if (normalized === undefined) {
241
+ // 物化失败的 route 由 normalizeOfficialRoutes 的 warn 通道报告;草稿
242
+ // (未配置模型的占位)本就不可服务,在此补一条可读说明。
243
+ if (isDraftRoute(raw)) warnings.push(`草稿 route "${route}" 未纳入副本(尚无模型)`)
244
+ continue
245
+ }
246
+ providers[route] = filterRoute(normalized, input.routeFields, input.modelFields)
247
+ }
248
+ // 手写官方条目(describe 的生效视图)同名 key 手写优先:那是用户的显式意图。
249
+ for (const [route, profile] of Object.entries(input.manualProviders ?? {})) {
250
+ if (profile !== null && typeof profile === 'object') providers[route] = profile
251
+ }
252
+ return { providers, warnings }
253
+ }
254
+
255
+ /** 渲染元数据(头注释里的应用指引)。 */
256
+ export interface OfficialCopyMeta {
257
+ /** 来源 profile 名(缺省以占位符渲染应用命令)。 */
258
+ profile: string | undefined
259
+ }
260
+
261
+ /** 头注释:用途、两种应用方式、切换语义、回退方法与警告清单(确定性输出,mtime 即生成时间)。 */
262
+ function renderHeader(copy: OfficialCopy, meta: OfficialCopyMeta): string {
263
+ const profile = meta.profile ?? '<profile>'
264
+ return [
265
+ '# 官方应急 LLM 配置副本 —— @dsh-plus/llm-pi 自动生成(勿手改:下次配置变更会被覆盖;文件 mtime 即最近生成时间)',
266
+ `# 来源 profile:${profile}`,
267
+ '# 用途:dsh-plus-llm-pi 缺席/故障时,用本文件把其 route 以官方 llm-pi-ai 配置恢复 LLM 能力。',
268
+ '# 应用(二选一,重启/重载后生效):',
269
+ `# 1) 单次启动:dsh ${profile} --patch ${OFFICIAL_COPY_PATH}`,
270
+ `# 2) 长期生效:把本文件条目并入该 profile 的 cordis.patch.yml(~/.dsh/profiles/${profile}/)`,
271
+ '# 语义:应用即切换 —— 副本禁用 dsh-plus-llm-pi,并整行替换 llm-pi-ai 的 config',
272
+ '# (官方 llm-pi-ai 现有 providers 已合并保留;同名 key 以手写条目优先)。',
273
+ '# 回退:移除 --patch 参数或删除并入的条目(含 dsh-plus-llm-pi 的 disabled 行),重启/重载。',
274
+ `# 本次内容:${Object.keys(copy.providers).length} 个官方 provider route。`,
275
+ ...copy.warnings.map((warning) => `# 警告:${warning}`),
276
+ ].join('\n')
277
+ }
278
+
279
+ /**
280
+ * 渲染副本文件内容(确定性:同输入同字节,mtime 承载时间语义)。
281
+ * 零 route 时渲染空补丁列表 `[]`(应用无效果)——绝不产出「只禁用不供给」的误导性副本。
282
+ */
283
+ export function renderOfficialCopy(copy: OfficialCopy, meta: OfficialCopyMeta): string {
284
+ const header = renderHeader(copy, meta)
285
+ if (Object.keys(copy.providers).length === 0) {
286
+ return `${header}\n# 当前没有可迁移的 route(llm-pi 未配置或全部不可服务),空补丁应用无效果。\n[]\n`
287
+ }
288
+ const rows = [
289
+ { id: 'dsh-plus-llm-pi', disabled: true },
290
+ { id: 'llm-pi-ai', config: { providers: copy.providers } },
291
+ ]
292
+ return `${header}\n${stringify(rows, { lineWidth: 0 })}`
293
+ }
package/src/profiles.ts CHANGED
@@ -710,6 +710,38 @@ export function buildProfiles(
710
710
  return resolved
711
711
  }
712
712
 
713
+ /**
714
+ * 应急副本用:逐 route 归一化为官方 schema 形状的 profile(与运行期同一条
715
+ * normalizeRoute 链:extends 物化、单协议单端点约束、模型条目全显式)。
716
+ *
717
+ * 与运行期 buildProfiles 的两点差异:
718
+ * - 不受 `enabled` 开关影响——副本是配置层事实,插件关闭时其 route 仍应可复制;
719
+ * - 逐 route 隔离失败(不依赖 lenient 开关)——一个坏条目只丢它自己,
720
+ * 不让整份应急副本变空。
721
+ *
722
+ * adapter: deepseek 的 route 与草稿路由不在此列(前者由 profiles-deepseek
723
+ * 物化、后者本就不可服务),调用方按缺席另行告警。
724
+ */
725
+ export function normalizeOfficialRoutes(
726
+ providers: Record<string, ProviderProfileConfig> | undefined,
727
+ deps: BuildDeps,
728
+ ): Map<string, PiAiProviderProfile> {
729
+ const out = new Map<string, PiAiProviderProfile>()
730
+ for (const [route, profile] of Object.entries(providers ?? {})) {
731
+ try {
732
+ const normalized = normalizeRoute(route, profile, deps)
733
+ if (normalized !== undefined) out.set(route, normalized.profile)
734
+ } catch (error) {
735
+ deps.warn?.(
736
+ `llm-pi: provider "${route}" 无法物化为官方形状(${
737
+ error instanceof Error ? error.message : String(error)
738
+ }),已跳过应急副本的该 route`,
739
+ )
740
+ }
741
+ }
742
+ return out
743
+ }
744
+
713
745
  /** settings 写入校验钩子:完整试跑解析,非法配置在写入处拒绝。 */
714
746
  export function assertServiceable(
715
747
  config: { providers?: Record<string, ProviderProfileConfig> },
@@ -93,6 +93,13 @@ export type ResolveProfiles = (
93
93
  providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
94
94
  ) => Map<string, ResolvedPiAiProviderProfile>
95
95
 
96
+ /**
97
+ * 官方 Config schema 的调用面(根导出 `Config`,schemastery schema 的可调用形态):
98
+ * 应急副本写前校验「官方是否接受这个形状」——非法协议/取值越界在此抛错。
99
+ * 只用其抛错语义,解析产物(volatile 视图)不消费。
100
+ */
101
+ export type OfficialConfigSchema = (value: unknown) => unknown
102
+
96
103
  /** PiAiAdapter 必需 auth 注入的两个助手(官方 auth.ts 面;包根未导出)。 */
97
104
  export interface AuthHelpers {
98
105
  credentialStoreFrom(ctx: Context): CredentialStore
@@ -117,6 +124,11 @@ export interface DshKit {
117
124
  /** 内置目录数据生成时间(毫秒);官方未导出该信息时省略。 */
118
125
  catalogGeneratedAt?: number
119
126
  PiAiAdapter: typeof PiAiAdapterType
127
+ /**
128
+ * 官方 `Config` schema(与 PiAiAdapter 同一模块根导出,同源取用):
129
+ * 应急副本落盘前的官方可识别性校验(见 {@link OfficialConfigSchema})。
130
+ */
131
+ officialConfig: OfficialConfigSchema
120
132
  LlmError: typeof DshLlm.LlmError
121
133
  resolveRetryPolicy: typeof DshLlm.resolveRetryPolicy
122
134
  attributionHeaders: typeof DshLlm.attributionHeaders
@@ -173,6 +185,7 @@ function assertKitShape(kit: DshKit, origin: string): void {
173
185
  }
174
186
  }
175
187
  if (typeof kit.createProvider !== 'function') problems.push('pi-ai createProvider 缺失')
188
+ if (typeof kit.officialConfig !== 'function') problems.push('dsh-llm-pi-ai Config schema 缺失')
176
189
  if (typeof kit.getBuiltinModels !== 'function') problems.push('pi-ai getBuiltinModels 缺失')
177
190
  if (typeof kit.builtinProviders !== 'function') problems.push('pi-ai builtinProviders 缺失')
178
191
  if (kit.protocols.length === 0) problems.push('没有可服务的线协议(协议工厂全部加载失败)')
@@ -529,6 +542,7 @@ function kitFromTree(options: {
529
542
  ? {}
530
543
  : { catalogGeneratedAt: surface.catalogGeneratedAt }),
531
544
  PiAiAdapter: mods.piAiAdapter['PiAiAdapter'] as DshKit['PiAiAdapter'],
545
+ officialConfig: mods.piAiAdapter['Config'] as DshKit['officialConfig'],
532
546
  LlmError: mods.llm['LlmError'] as DshKit['LlmError'],
533
547
  resolveRetryPolicy: mods.llm['resolveRetryPolicy'] as DshKit['resolveRetryPolicy'],
534
548
  attributionHeaders: mods.llm['attributionHeaders'] as DshKit['attributionHeaders'],
@@ -626,6 +640,7 @@ export function loadVendoredKit(diagnostics: string[] = []): DshKit {
626
640
  ? {}
627
641
  : { catalogGeneratedAt: surface.catalogGeneratedAt }),
628
642
  PiAiAdapter: vendoredPiAiAdapter.PiAiAdapter,
643
+ officialConfig: vendoredPiAiAdapter.Config as DshKit['officialConfig'],
629
644
  LlmError: vendoredLlm.LlmError,
630
645
  resolveRetryPolicy: vendoredLlm.resolveRetryPolicy,
631
646
  attributionHeaders: vendoredLlm.attributionHeaders,