@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/LICENSE +21 -0
- package/lib/client.js +1904 -0
- package/lib/index.d.ts +90 -0
- package/lib/index.js +1247 -0
- package/package.json +62 -0
- package/src/catalog/builtin.ts +75 -0
- package/src/catalog/models-dev.ts +248 -0
- package/src/client/api.ts +109 -0
- package/src/client/card.tsx +239 -0
- package/src/client/client.ts +63 -0
- package/src/client/constants.ts +97 -0
- package/src/client/draft.ts +293 -0
- package/src/client/fields.tsx +272 -0
- package/src/client/i18n.ts +184 -0
- package/src/client/styles.ts +92 -0
- package/src/client/views/compat.tsx +104 -0
- package/src/client/views/models.tsx +292 -0
- package/src/client/views/provider-fields.tsx +177 -0
- package/src/client/views/providers.tsx +176 -0
- package/src/compat.ts +146 -0
- package/src/config-api.ts +135 -0
- package/src/config.ts +210 -0
- package/src/discovery.ts +176 -0
- package/src/index.ts +33 -0
- package/src/inherit.ts +85 -0
- package/src/profiles.ts +340 -0
- package/src/resolve-dsh.ts +203 -0
- package/src/service.ts +238 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 插件:自定义 LLM 路由(方案 4 基准层)。
|
|
3
|
+
*
|
|
4
|
+
* 复用官方 PiAiAdapter(消息互译/护栏零重写),经官方承认的 profiles 回调
|
|
5
|
+
* 接缝注入插件自建的 provider 物化产物,从而获得:
|
|
6
|
+
* - 全量 compat(逐协议字段表,写入即校验,对比官方的 2 字段+静默丢弃);
|
|
7
|
+
* - 模型继承(内置目录 → models.dev 快照 → 手写,用户显式字段最终覆盖);
|
|
8
|
+
* - 模块解析优先 dsh 安装树(dsh 升级即自动跟随上游),vendored 副本兜底。
|
|
9
|
+
*
|
|
10
|
+
* 注册全新的 route 名,不触碰官方 llm-pi-ai 的任何 route 与配置。
|
|
11
|
+
* @module @dsh-plus/llm-pi
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
|
|
15
|
+
import { Config, type LlmPiConfig } from './config.ts'
|
|
16
|
+
import { registerConfigApi } from './config-api.ts'
|
|
17
|
+
import { startRuntime } from './service.ts'
|
|
18
|
+
|
|
19
|
+
export const name = 'dsh-plus-llm-pi'
|
|
20
|
+
|
|
21
|
+
export const inject = ['llm'] as const
|
|
22
|
+
|
|
23
|
+
export { Config }
|
|
24
|
+
|
|
25
|
+
export type { LlmPiConfig, ProviderProfileConfig, ModelEntryConfig, WireConfig } from './config.ts'
|
|
26
|
+
export { SETTINGS_NS } from './config.ts'
|
|
27
|
+
|
|
28
|
+
export async function apply(ctx: Context, config: LlmPiConfig): Promise<void> {
|
|
29
|
+
const runtime = await startRuntime(ctx, config)
|
|
30
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
31
|
+
registerConfigApi(webCtx, runtime)
|
|
32
|
+
})
|
|
33
|
+
}
|
package/src/inherit.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extends 继承解析:把 provider/model 条目上的继承引用解析为继承 base。
|
|
3
|
+
*
|
|
4
|
+
* 数据源优先级(三级):
|
|
5
|
+
* 1. pi-ai 内置目录(含官方校正,最可信);
|
|
6
|
+
* 2. models.dev 快照(仅内置未收录的新模型,字段保守);
|
|
7
|
+
* 3. 都未命中 → 手写模型(无 base,必填字段由配置/兜底给出)。
|
|
8
|
+
*
|
|
9
|
+
* 引用语法:`"provider/model"` 显式跨源;裸 `"model"` 随 route 级 extends 源;
|
|
10
|
+
* 条目缺省 extends 时以 route extends 源下的同名模型为 base。
|
|
11
|
+
* @module llm-pi/inherit
|
|
12
|
+
*/
|
|
13
|
+
import type { ModelBase } from './catalog/builtin.ts'
|
|
14
|
+
import { builtinModelBase } from './catalog/builtin.ts'
|
|
15
|
+
import type { ModelsDevSource } from './catalog/models-dev.ts'
|
|
16
|
+
import type { ModelEntryConfig, ProviderProfileConfig } from './config.ts'
|
|
17
|
+
import type { DshKit } from './resolve-dsh.ts'
|
|
18
|
+
|
|
19
|
+
export interface BaseResolution {
|
|
20
|
+
base: ModelBase
|
|
21
|
+
source: 'builtin' | 'models-dev' | 'none'
|
|
22
|
+
/** 实际命中的继承源 provider(诊断/错误消息用)。 */
|
|
23
|
+
sourceProvider?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ExtendsError extends Error {}
|
|
27
|
+
|
|
28
|
+
/** 解析 "provider/model" 或裸 "model" 引用。 */
|
|
29
|
+
export function parseExtendsRef(raw: string): { provider?: string; model: string } {
|
|
30
|
+
const slash = raw.indexOf('/')
|
|
31
|
+
if (slash < 0) return { model: raw }
|
|
32
|
+
const provider = raw.slice(0, slash)
|
|
33
|
+
const model = raw.slice(slash + 1)
|
|
34
|
+
if (provider.length === 0 || model.length === 0 || model.includes('/')) {
|
|
35
|
+
throw new ExtendsError(`extends 引用 ${JSON.stringify(raw)} 非法:应为 "provider/model" 或 "model"`)
|
|
36
|
+
}
|
|
37
|
+
return { provider, model }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lookup(
|
|
41
|
+
kit: DshKit,
|
|
42
|
+
modelsDev: ModelsDevSource | undefined,
|
|
43
|
+
provider: string,
|
|
44
|
+
model: string,
|
|
45
|
+
): BaseResolution | undefined {
|
|
46
|
+
const builtin = builtinModelBase(kit, provider, model)
|
|
47
|
+
if (builtin !== undefined) return { base: builtin, source: 'builtin', sourceProvider: provider }
|
|
48
|
+
const dev = modelsDev?.lookup(provider, model)
|
|
49
|
+
if (dev !== undefined) return { base: dev, source: 'models-dev', sourceProvider: provider }
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 解析一个模型条目的继承 base。
|
|
55
|
+
* @throws ExtendsError 显式 extends 引用不存在(写入时拒绝,指明引用名)。
|
|
56
|
+
*/
|
|
57
|
+
export function resolveModelBase(
|
|
58
|
+
route: string,
|
|
59
|
+
profile: ProviderProfileConfig,
|
|
60
|
+
entry: ModelEntryConfig,
|
|
61
|
+
kit: DshKit,
|
|
62
|
+
modelsDev: ModelsDevSource | undefined,
|
|
63
|
+
): BaseResolution {
|
|
64
|
+
const where = `provider "${route}" model "${entry.id}"`
|
|
65
|
+
if (entry.extends === undefined) {
|
|
66
|
+
if (profile.extends === undefined) return { base: {}, source: 'none' }
|
|
67
|
+
return (
|
|
68
|
+
lookup(kit, modelsDev, profile.extends, entry.id) ?? { base: {}, source: 'none' }
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
const ref = parseExtendsRef(entry.extends)
|
|
72
|
+
const provider = ref.provider ?? profile.extends
|
|
73
|
+
if (provider === undefined) {
|
|
74
|
+
throw new ExtendsError(
|
|
75
|
+
`${where}: extends ${JSON.stringify(entry.extends)} 是裸模型 id,但本 route 未配置 provider 级 extends 查找源`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
const hit = lookup(kit, modelsDev, provider, ref.model)
|
|
79
|
+
if (hit === undefined) {
|
|
80
|
+
throw new ExtendsError(
|
|
81
|
+
`${where}: extends 引用 "${provider}/${ref.model}" 在内置目录与 models.dev 快照中都不存在`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
return hit
|
|
85
|
+
}
|
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile 构建器:插件 Config → ResolvedPiAiProviderProfile 映射。
|
|
3
|
+
*
|
|
4
|
+
* 语义与官方 dsh-llm-pi-ai 的 resolveProfiles 链逐点对齐(index.js:1068-1471),
|
|
5
|
+
* 差异仅在两点扩展:
|
|
6
|
+
* - base 来源从"route 同名内置目录"换为 extends 三级数据源(inherit.ts);
|
|
7
|
+
* - compat 从 2 字段枚举扩展为逐协议全量字段(compat.ts),写入即校验。
|
|
8
|
+
*
|
|
9
|
+
* 产物经 `new PiAiAdapter({ profiles })` 进入官方消息链路;
|
|
10
|
+
* 任何不可服务的配置在此处抛错(命名 route/model),配合 settings 写入校验,
|
|
11
|
+
* 非法配置在写入时被拒绝、运行期保留上一份好配置。
|
|
12
|
+
* @module llm-pi/profiles
|
|
13
|
+
*/
|
|
14
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
15
|
+
import type { ResolvedPiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
16
|
+
|
|
17
|
+
import { inheritedCatalogEntries, builtinProviderBaseUrl, type ModelBase } from './catalog/builtin.ts'
|
|
18
|
+
import type { ModelsDevSource } from './catalog/models-dev.ts'
|
|
19
|
+
import { mergeCompat, validateCompat } from './compat.ts'
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
22
|
+
DEFAULT_MAX_TOKENS,
|
|
23
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
24
|
+
MAX_TIMER_DELAY_MS,
|
|
25
|
+
THINKING_LEVELS,
|
|
26
|
+
type ModelEntryConfig,
|
|
27
|
+
type ProviderProfileConfig,
|
|
28
|
+
} from './config.ts'
|
|
29
|
+
import { ExtendsError, resolveModelBase } from './inherit.ts'
|
|
30
|
+
import type { DshKit } from './resolve-dsh.ts'
|
|
31
|
+
|
|
32
|
+
/** 内置目录未描述时的零价目(harness 不消费 cost 元数据,同官方 NO_COST)。 */
|
|
33
|
+
const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
34
|
+
|
|
35
|
+
export interface BuildDeps {
|
|
36
|
+
kit: DshKit
|
|
37
|
+
modelsDev?: ModelsDevSource
|
|
38
|
+
/**
|
|
39
|
+
* 运行期宽松模式:数据源漂移(models.dev 刷新/内置目录变化)导致已写入的
|
|
40
|
+
* extends 引用失效时,降级/跳过并告警,而不是抛错把整个 route 弄挂。
|
|
41
|
+
* 写时校验(assertServiceable)保持严格(缺省),非法引用在写入处拒绝。
|
|
42
|
+
*/
|
|
43
|
+
lenient?: boolean
|
|
44
|
+
/** lenient 模式下的告警通道(service 层接 logger)。 */
|
|
45
|
+
warn?: (message: string) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 报告不可服务的 route,命名出错配置键(对齐官方 invalid())。 */
|
|
49
|
+
function invalid(provider: string, detail: string): never {
|
|
50
|
+
throw new Error(`llm-pi: provider "${provider}" ${detail}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 条目的声明模态;缺省/空数组都视为"无答案",交下一级(同官方 declaredInput)。 */
|
|
54
|
+
function declaredInput(configured: readonly ('text' | 'image')[] | undefined): ('text' | 'image')[] | undefined {
|
|
55
|
+
return configured === undefined || configured.length === 0 ? undefined : [...configured]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 单个模型的 reasoning 物化(逐行对齐官方 resolveModelReasoning):
|
|
60
|
+
* 显式 dict → 全档位确定的 thinkingLevelMap(未声明档位置 null);
|
|
61
|
+
* false → 非推理模型;缺省 → 保留继承源的 reasoning 能力。
|
|
62
|
+
*/
|
|
63
|
+
function resolveModelReasoning(
|
|
64
|
+
provider: string,
|
|
65
|
+
entry: ModelEntryConfig,
|
|
66
|
+
base: ModelBase,
|
|
67
|
+
): { reasoning: boolean; thinkingLevelMap?: Record<string, string | null> } {
|
|
68
|
+
const efforts = entry.reasoningEfforts
|
|
69
|
+
if (efforts === undefined) {
|
|
70
|
+
if (base.reasoning === undefined) return { reasoning: false }
|
|
71
|
+
return {
|
|
72
|
+
reasoning: base.reasoning,
|
|
73
|
+
...(base.thinkingLevelMap === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { thinkingLevelMap: base.thinkingLevelMap as Record<string, string | null> }),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (efforts === false) return { reasoning: false }
|
|
79
|
+
if (Object.keys(efforts).length === 0) {
|
|
80
|
+
invalid(provider, `model "${entry.id}" 的 reasoningEfforts 为空:声明档位、置 false 或缺省继承`)
|
|
81
|
+
}
|
|
82
|
+
for (const level of THINKING_LEVELS) {
|
|
83
|
+
const wire = (efforts as Record<string, string | null | undefined>)[level]
|
|
84
|
+
if (wire === undefined) continue
|
|
85
|
+
if (wire === null) {
|
|
86
|
+
if (level !== 'off') invalid(provider, `model "${entry.id}" reasoningEfforts.${level} 需要线值;仅 off 可留空`)
|
|
87
|
+
} else if (wire.length === 0) {
|
|
88
|
+
invalid(provider, `model "${entry.id}" reasoningEfforts.${level} 不能为空字符串`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const declared = THINKING_LEVELS.filter((level) => (efforts as Record<string, unknown>)[level] !== undefined)
|
|
92
|
+
if (!declared.some((level) => level !== 'off')) {
|
|
93
|
+
invalid(provider, `model "${entry.id}" reasoningEfforts 只有 off;声明思考档位或置 false`)
|
|
94
|
+
}
|
|
95
|
+
const map: Record<string, string | null> = {}
|
|
96
|
+
for (const level of THINKING_LEVELS) {
|
|
97
|
+
const wire = (efforts as Record<string, string | null | undefined>)[level]
|
|
98
|
+
if (wire === undefined) map[level] = null
|
|
99
|
+
else if (wire !== null) map[level] = wire
|
|
100
|
+
}
|
|
101
|
+
return { reasoning: true, thinkingLevelMap: map }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Harness 自管凭据的 api-key auth(对齐官方 harnessApiKeyAuth,index.js:1215)。 */
|
|
105
|
+
function harnessApiKeyAuth(name: string) {
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
resolve: ({ credential }: { credential?: { key?: string } }) =>
|
|
109
|
+
Promise.resolve({ auth: credential?.key === undefined ? {} : { apiKey: credential.key }, source: name }),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface MaterializedModel {
|
|
114
|
+
id: string
|
|
115
|
+
name: string
|
|
116
|
+
api: string
|
|
117
|
+
provider: string
|
|
118
|
+
baseUrl: string
|
|
119
|
+
reasoning: boolean
|
|
120
|
+
thinkingLevelMap?: Record<string, string | null>
|
|
121
|
+
input: ('text' | 'image')[]
|
|
122
|
+
cost: typeof NO_COST
|
|
123
|
+
contextWindow: number
|
|
124
|
+
maxTokens: number
|
|
125
|
+
headers?: Record<string, string>
|
|
126
|
+
compat?: Record<string, unknown>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 物化单个模型:继承 base 在下,条目显式字段逐字段覆盖。lenient 下缺 api/baseURL 时跳过(返回 null)。 */
|
|
130
|
+
function materializeModel(
|
|
131
|
+
route: string,
|
|
132
|
+
profile: ProviderProfileConfig,
|
|
133
|
+
entry: ModelEntryConfig,
|
|
134
|
+
base: ModelBase,
|
|
135
|
+
routeApi: string | undefined,
|
|
136
|
+
providerBaseUrl: string | undefined,
|
|
137
|
+
defaultInput: ('text' | 'image')[],
|
|
138
|
+
deps: BuildDeps,
|
|
139
|
+
configuredMaxTokens: Map<string, number>,
|
|
140
|
+
): MaterializedModel | null {
|
|
141
|
+
const api = profile.api ?? base.api ?? routeApi
|
|
142
|
+
if (api === undefined) {
|
|
143
|
+
if (deps.lenient) {
|
|
144
|
+
deps.warn?.(`llm-pi: provider "${route}" model "${entry.id}" 无法获得 api(继承源缺失),已跳过该模型`)
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
invalid(route, `model "${entry.id}" 需要 api:继承源未提供,请在 route 上设置 api`)
|
|
148
|
+
}
|
|
149
|
+
const baseUrl = profile.baseURL ?? base.baseUrl ?? providerBaseUrl
|
|
150
|
+
if (baseUrl === undefined) {
|
|
151
|
+
if (deps.lenient) {
|
|
152
|
+
deps.warn?.(`llm-pi: provider "${route}" model "${entry.id}" 无法获得 baseURL(继承源缺失),已跳过该模型`)
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
invalid(route, `model "${entry.id}" 需要 baseURL:继承源未提供,请在 route 上设置 baseURL`)
|
|
156
|
+
}
|
|
157
|
+
const contextWindow = entry.contextWindow ?? base.contextWindow ?? profile.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW
|
|
158
|
+
const maxTokens = entry.maxTokens ?? base.maxTokens ?? profile.defaultMaxTokens ?? DEFAULT_MAX_TOKENS
|
|
159
|
+
if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens)
|
|
160
|
+
validateCompat(api, profile.compat as Record<string, unknown> | undefined, `provider "${route}"`)
|
|
161
|
+
validateCompat(api, entry.compat as Record<string, unknown> | undefined, `provider "${route}" model "${entry.id}"`)
|
|
162
|
+
const compat = mergeCompat(
|
|
163
|
+
base.api === api ? base.compat : undefined,
|
|
164
|
+
profile.compat as Record<string, unknown> | undefined,
|
|
165
|
+
entry.compat as Record<string, unknown> | undefined,
|
|
166
|
+
)
|
|
167
|
+
return {
|
|
168
|
+
id: entry.id,
|
|
169
|
+
name: entry.name ?? base.name ?? entry.id,
|
|
170
|
+
api,
|
|
171
|
+
provider: route,
|
|
172
|
+
baseUrl,
|
|
173
|
+
...resolveModelReasoning(route, entry, base),
|
|
174
|
+
input: declaredInput(entry.input) ?? base.input ?? [...defaultInput],
|
|
175
|
+
cost: base.cost ?? NO_COST,
|
|
176
|
+
contextWindow,
|
|
177
|
+
maxTokens,
|
|
178
|
+
...(base.headers === undefined ? {} : { headers: { ...base.headers } }),
|
|
179
|
+
...(compat === undefined ? {} : { compat }),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 物化一个 route 的全部模型;返回模型列表与显式配置的请求 cap 表。
|
|
184
|
+
* lenient 下 route 无任何可服务模型时返回 null(调用方跳过该 route)。 */
|
|
185
|
+
function materializeRouteModels(
|
|
186
|
+
route: string,
|
|
187
|
+
profile: ProviderProfileConfig,
|
|
188
|
+
deps: BuildDeps,
|
|
189
|
+
defaultInput: ('text' | 'image')[],
|
|
190
|
+
): { models: MaterializedModel[]; configuredMaxTokens: Map<string, number> } | null {
|
|
191
|
+
const configuredMaxTokens = new Map<string, number>()
|
|
192
|
+
const providerBaseUrl =
|
|
193
|
+
profile.extends === undefined ? undefined : builtinProviderBaseUrl(deps.kit, profile.extends)
|
|
194
|
+
const entries: { id: string; entry?: ModelEntryConfig; base: ModelBase }[] = []
|
|
195
|
+
if (profile.models !== undefined && profile.models.length > 0) {
|
|
196
|
+
const seen = new Set<string>()
|
|
197
|
+
for (const entry of profile.models) {
|
|
198
|
+
if (entry.id.length === 0) invalid(route, '存在空 id 的模型条目')
|
|
199
|
+
if (seen.has(entry.id)) invalid(route, `模型 "${entry.id}" 重复列出`)
|
|
200
|
+
seen.add(entry.id)
|
|
201
|
+
let base: ModelBase
|
|
202
|
+
try {
|
|
203
|
+
base = resolveModelBase(route, profile, entry, deps.kit, deps.modelsDev).base
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// 运行期数据源漂移:已写入的引用在当前目录 miss。严格模式(写时校验)保持拒绝;
|
|
206
|
+
// lenient 模式降级为手写条目并告警,避免整个 route 不可服务。
|
|
207
|
+
if (!deps.lenient || !(error instanceof ExtendsError)) throw error
|
|
208
|
+
deps.warn?.(
|
|
209
|
+
`llm-pi: provider "${route}" model "${entry.id}" 的 extends 引用当前不可解析` +
|
|
210
|
+
`(${error.message});已降级为手写条目`,
|
|
211
|
+
)
|
|
212
|
+
base = {}
|
|
213
|
+
}
|
|
214
|
+
entries.push({ id: entry.id, entry, base })
|
|
215
|
+
}
|
|
216
|
+
} else if (profile.extends !== undefined) {
|
|
217
|
+
for (const { id, base } of inheritedCatalogEntries(deps.kit, profile.extends)) {
|
|
218
|
+
entries.push({ id, base })
|
|
219
|
+
}
|
|
220
|
+
if (entries.length === 0) {
|
|
221
|
+
invalid(route, `extends 源 "${profile.extends}" 在内置目录中没有模型;请显式列出 models`)
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
invalid(route, '未配置 models 且未配置 provider 级 extends;本 route 无模型可服务')
|
|
225
|
+
}
|
|
226
|
+
const apis = new Set(
|
|
227
|
+
entries
|
|
228
|
+
.map(({ base }) => profile.api ?? base.api)
|
|
229
|
+
.filter((api): api is string => api !== undefined),
|
|
230
|
+
)
|
|
231
|
+
const routeApi = apis.size === 1 ? [...apis][0] : undefined
|
|
232
|
+
const models = entries
|
|
233
|
+
.map(({ id, entry, base }) =>
|
|
234
|
+
materializeModel(
|
|
235
|
+
route,
|
|
236
|
+
profile,
|
|
237
|
+
entry ?? { id },
|
|
238
|
+
base,
|
|
239
|
+
routeApi,
|
|
240
|
+
providerBaseUrl,
|
|
241
|
+
defaultInput,
|
|
242
|
+
deps,
|
|
243
|
+
configuredMaxTokens,
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
.filter((model): model is MaterializedModel => model !== null)
|
|
247
|
+
if (models.length === 0) {
|
|
248
|
+
if (deps.lenient) {
|
|
249
|
+
deps.warn?.(`llm-pi: provider "${route}" 当前没有可服务的模型(继承源漂移),已跳过该 route 的注册`)
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
invalid(route, 'route 内没有可服务的模型')
|
|
253
|
+
}
|
|
254
|
+
const finalApis = new Set(models.map((m) => m.api))
|
|
255
|
+
if (finalApis.size > 1) {
|
|
256
|
+
invalid(route, `route 内模型协议不一致(${[...finalApis].join(', ')});一个 route 只能服务一种协议`)
|
|
257
|
+
}
|
|
258
|
+
return { models, configuredMaxTokens }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 校验并物化全部 route。任一 route 不可服务即整体抛错——
|
|
263
|
+
* settings 写入校验与运行期 profiles 回调共用本函数,
|
|
264
|
+
* 保证"写入时被拒"与"运行期不可能拿到坏配置"互为表里。
|
|
265
|
+
*/
|
|
266
|
+
export function buildProfiles(
|
|
267
|
+
providers: Record<string, ProviderProfileConfig> | undefined,
|
|
268
|
+
deps: BuildDeps,
|
|
269
|
+
): Map<string, ResolvedPiAiProviderProfile> {
|
|
270
|
+
const resolved = new Map<string, ResolvedPiAiProviderProfile>()
|
|
271
|
+
for (const [route, profile] of Object.entries(providers ?? {})) {
|
|
272
|
+
if (route.length === 0) throw new Error('llm-pi: provider 名不能为空')
|
|
273
|
+
// 注意:不做"route 名与内置 provider 名重名"的静态校验——内置名 ≠ 已注册
|
|
274
|
+
// route(如官方 llm-pi-ai 配置清空后 anthropic 名可用)。真实冲突只在注册期
|
|
275
|
+
// 暴露(DUPLICATE_ADAPTER),由 service 层逐个 route 注册降级处理。
|
|
276
|
+
if (profile.baseURL !== undefined && profile.baseURL.length === 0) invalid(route, 'baseURL 为空')
|
|
277
|
+
if (profile.displayName !== undefined && profile.displayName.length === 0) invalid(route, 'displayName 为空')
|
|
278
|
+
const streamIdleTimeoutMs = profile.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
|
|
279
|
+
if (
|
|
280
|
+
!Number.isFinite(streamIdleTimeoutMs) ||
|
|
281
|
+
streamIdleTimeoutMs <= 0 ||
|
|
282
|
+
streamIdleTimeoutMs > MAX_TIMER_DELAY_MS
|
|
283
|
+
) {
|
|
284
|
+
invalid(route, `streamIdleTimeoutMs 必须是 (0, ${MAX_TIMER_DELAY_MS}] 内的有限数`)
|
|
285
|
+
}
|
|
286
|
+
const defaultInput = [...(profile.defaultInput ?? ['text'])] as ('text' | 'image')[]
|
|
287
|
+
if (defaultInput.length === 0) invalid(route, 'defaultInput 至少要声明一种模态')
|
|
288
|
+
const displayName = profile.displayName ?? route
|
|
289
|
+
const catalog = materializeRouteModels(route, profile, deps, defaultInput)
|
|
290
|
+
if (catalog === null) continue // lenient 下 route 无模型可服务,跳过注册
|
|
291
|
+
const api = catalog.models[0]?.api
|
|
292
|
+
const factory = api === undefined ? undefined : deps.kit.protocolFactories[api as keyof DshKit['protocolFactories']]
|
|
293
|
+
if (factory === undefined) {
|
|
294
|
+
invalid(route, `api ${JSON.stringify(api)} 本插件无法服务(支持:openai-completions/openai-responses/anthropic-messages)`)
|
|
295
|
+
}
|
|
296
|
+
const piProvider = deps.kit.createProvider({
|
|
297
|
+
id: route,
|
|
298
|
+
name: displayName,
|
|
299
|
+
...(profile.baseURL === undefined
|
|
300
|
+
? profile.extends === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { baseUrl: builtinProviderBaseUrl(deps.kit, profile.extends) }
|
|
303
|
+
: { baseUrl: profile.baseURL }),
|
|
304
|
+
...(profile.headers === undefined ? {} : { headers: { ...profile.headers } }),
|
|
305
|
+
auth: { apiKey: harnessApiKeyAuth(displayName) },
|
|
306
|
+
models: catalog.models,
|
|
307
|
+
api: factory() as never,
|
|
308
|
+
})
|
|
309
|
+
resolved.set(route, {
|
|
310
|
+
provider: route,
|
|
311
|
+
displayName,
|
|
312
|
+
...(profile.apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(profile.apiKeyEnv) }),
|
|
313
|
+
streamIdleTimeoutMs,
|
|
314
|
+
retryPolicy: deps.kit.resolveRetryPolicy(
|
|
315
|
+
profile.retryPolicy as Parameters<DshKit['resolveRetryPolicy']>[0],
|
|
316
|
+
`llm-pi: provider "${route}" retryPolicy`,
|
|
317
|
+
),
|
|
318
|
+
...(profile.headers === undefined ? {} : { headers: { ...profile.headers } }),
|
|
319
|
+
...(profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }),
|
|
320
|
+
...(profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...profile.thinkingBudgets } }),
|
|
321
|
+
...(profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }),
|
|
322
|
+
...(profile.transport === undefined ? {} : { transport: profile.transport }),
|
|
323
|
+
...(profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs }),
|
|
324
|
+
...(profile.websocketConnectTimeoutMs === undefined
|
|
325
|
+
? {}
|
|
326
|
+
: { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs }),
|
|
327
|
+
configuredMaxTokens: catalog.configuredMaxTokens,
|
|
328
|
+
piProvider,
|
|
329
|
+
} as ResolvedPiAiProviderProfile)
|
|
330
|
+
}
|
|
331
|
+
return resolved
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** settings 写入校验钩子:完整试跑解析,非法配置在写入处拒绝。 */
|
|
335
|
+
export function assertServiceable(
|
|
336
|
+
config: { providers?: Record<string, ProviderProfileConfig> },
|
|
337
|
+
deps: BuildDeps,
|
|
338
|
+
): void {
|
|
339
|
+
buildProfiles(config.providers, deps)
|
|
340
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 运行时套件解析:让插件跑在 dsh 当前安装树的同一份
|
|
3
|
+
* dsh-llm-pi-ai / dsh-llm / pi-ai 之上,从而 dsh 升级即自动跟随上游。
|
|
4
|
+
*
|
|
5
|
+
* 解析策略(全部模块同源地整体成功或整体回退,杜绝跨源混用):
|
|
6
|
+
* 1. dsh-tree:从 process.argv[1](systemd/CLI 启动的 dsh 即 bin.js shim,
|
|
7
|
+
* realpath 后落在 dsh 安装树内)向上找到同时含有
|
|
8
|
+
* node_modules/@deepseek-ai/dsh-llm-pi-ai 与 node_modules/@earendil-works/pi-ai
|
|
9
|
+
* 的目录,按文件路径动态 import——与 dsh 官方插件共享同一模块实例;
|
|
10
|
+
* 2. vendored:回退到本插件 dependencies 里的固定版本副本(裸 import)。
|
|
11
|
+
*
|
|
12
|
+
* 两条路径产物都过形状自检(assertKitShape):上游 rc 重构导致形状漂移时
|
|
13
|
+
* 抛错,由调用方决定回退或放弃注册——绝不让坏套件进入消息链路。
|
|
14
|
+
* @module llm-pi/resolve-dsh
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { pathToFileURL } from 'node:url'
|
|
19
|
+
|
|
20
|
+
import type { PiAiAdapter as PiAiAdapterType } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
21
|
+
import type * as DshLlm from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import type * as PiAi from '@earendil-works/pi-ai'
|
|
23
|
+
|
|
24
|
+
import * as vendoredPiAi from '@earendil-works/pi-ai'
|
|
25
|
+
import * as vendoredCatalog from '@earendil-works/pi-ai/providers/all'
|
|
26
|
+
import { anthropicMessagesApi as vendoredAnthropic } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
|
|
27
|
+
import { openAICompletionsApi as vendoredCompletions } from '@earendil-works/pi-ai/api/openai-completions.lazy'
|
|
28
|
+
import { openAIResponsesApi as vendoredResponses } from '@earendil-works/pi-ai/api/openai-responses.lazy'
|
|
29
|
+
import * as vendoredLlm from '@deepseek-ai/dsh-llm'
|
|
30
|
+
import * as vendoredPiAiAdapter from '@deepseek-ai/dsh-llm-pi-ai'
|
|
31
|
+
|
|
32
|
+
import type { ProtocolId } from './config.ts'
|
|
33
|
+
|
|
34
|
+
/** 插件运行期所需的全部上游模块表面(单一来源,内部一致)。 */
|
|
35
|
+
export interface DshKit {
|
|
36
|
+
/** 解析来源:dsh 安装树 / 插件 vendored 兜底副本。 */
|
|
37
|
+
source: 'dsh-tree' | 'vendored'
|
|
38
|
+
PiAiAdapter: typeof PiAiAdapterType
|
|
39
|
+
LlmError: typeof DshLlm.LlmError
|
|
40
|
+
resolveRetryPolicy: typeof DshLlm.resolveRetryPolicy
|
|
41
|
+
attributionHeaders: typeof DshLlm.attributionHeaders
|
|
42
|
+
normalizeApiKey: typeof DshLlm.normalizeApiKey
|
|
43
|
+
assertUsableApiKey: typeof DshLlm.assertUsableApiKey
|
|
44
|
+
INVALID_CREDENTIAL_CODE: string
|
|
45
|
+
createProvider: typeof PiAi.createProvider
|
|
46
|
+
builtinProviders: typeof vendoredCatalog.builtinProviders
|
|
47
|
+
getBuiltinProviders: typeof vendoredCatalog.getBuiltinProviders
|
|
48
|
+
getBuiltinModels: typeof vendoredCatalog.getBuiltinModels
|
|
49
|
+
/** 三协议的 pi-ai api 实现工厂(与官方 PROTOCOLS 表同来源)。 */
|
|
50
|
+
protocolFactories: Record<ProtocolId, () => unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** kit 必备形状清单:缺失即视为上游不兼容。 */
|
|
54
|
+
function assertKitShape(kit: DshKit, origin: string): void {
|
|
55
|
+
const problems: string[] = []
|
|
56
|
+
if (typeof kit.PiAiAdapter !== 'function') problems.push('PiAiAdapter 不是类')
|
|
57
|
+
else {
|
|
58
|
+
for (const method of ['current', 'stream', 'listModels', 'resolveModel', 'providerInfo'] as const) {
|
|
59
|
+
if (typeof (kit.PiAiAdapter.prototype as Record<string, unknown>)[method] !== 'function') {
|
|
60
|
+
problems.push(`PiAiAdapter.prototype.${method} 缺失`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (typeof kit.createProvider !== 'function') problems.push('pi-ai createProvider 缺失')
|
|
65
|
+
if (typeof kit.getBuiltinModels !== 'function') problems.push('pi-ai getBuiltinModels 缺失')
|
|
66
|
+
if (typeof kit.builtinProviders !== 'function') problems.push('pi-ai builtinProviders 缺失')
|
|
67
|
+
for (const [api, factory] of Object.entries(kit.protocolFactories)) {
|
|
68
|
+
if (typeof factory !== 'function') problems.push(`协议工厂 ${api} 缺失`)
|
|
69
|
+
}
|
|
70
|
+
if (typeof kit.LlmError !== 'function') problems.push('dsh-llm LlmError 缺失')
|
|
71
|
+
if (typeof kit.resolveRetryPolicy !== 'function') problems.push('dsh-llm resolveRetryPolicy 缺失')
|
|
72
|
+
if (problems.length > 0) {
|
|
73
|
+
throw new Error(`llm-pi: ${origin} 来源的运行时套件形状不兼容:${problems.join(';')}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 从 startDir 向上找同时含有 dsh-llm-pi-ai 与 pi-ai 的安装树根。 */
|
|
78
|
+
function findDshTreeRoot(startDir: string): string | undefined {
|
|
79
|
+
let dir = startDir
|
|
80
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
81
|
+
const nm = join(dir, 'node_modules')
|
|
82
|
+
if (
|
|
83
|
+
existsSync(join(nm, '@deepseek-ai', 'dsh-llm-pi-ai', 'lib', 'index.js')) &&
|
|
84
|
+
existsSync(join(nm, '@earendil-works', 'pi-ai', 'dist', 'index.js'))
|
|
85
|
+
) {
|
|
86
|
+
return dir
|
|
87
|
+
}
|
|
88
|
+
const parent = dirname(dir)
|
|
89
|
+
if (parent === dir) return undefined
|
|
90
|
+
dir = parent
|
|
91
|
+
}
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface TreeModules {
|
|
96
|
+
piAiAdapter: Record<string, unknown>
|
|
97
|
+
llm: Record<string, unknown>
|
|
98
|
+
piAi: Record<string, unknown>
|
|
99
|
+
catalog: Record<string, unknown>
|
|
100
|
+
completions: Record<string, unknown>
|
|
101
|
+
responses: Record<string, unknown>
|
|
102
|
+
anthropic: Record<string, unknown>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 从 dsh 安装树按文件路径动态 import 全部套件模块(与官方插件同实例)。 */
|
|
106
|
+
async function importTreeModules(root: string): Promise<TreeModules> {
|
|
107
|
+
const nm = join(root, 'node_modules')
|
|
108
|
+
const load = (absPath: string): Promise<Record<string, unknown>> =>
|
|
109
|
+
import(pathToFileURL(absPath).href) as Promise<Record<string, unknown>>
|
|
110
|
+
const [piAiAdapter, llm, piAi, catalog, completions, responses, anthropic] = await Promise.all([
|
|
111
|
+
load(join(nm, '@deepseek-ai', 'dsh-llm-pi-ai', 'lib', 'index.js')),
|
|
112
|
+
load(join(nm, '@deepseek-ai', 'dsh-llm', 'lib', 'index.js')),
|
|
113
|
+
load(join(nm, '@earendil-works', 'pi-ai', 'dist', 'index.js')),
|
|
114
|
+
load(join(nm, '@earendil-works', 'pi-ai', 'dist', 'providers', 'all.js')),
|
|
115
|
+
load(join(nm, '@earendil-works', 'pi-ai', 'dist', 'api', 'openai-completions.lazy.js')),
|
|
116
|
+
load(join(nm, '@earendil-works', 'pi-ai', 'dist', 'api', 'openai-responses.lazy.js')),
|
|
117
|
+
load(join(nm, '@earendil-works', 'pi-ai', 'dist', 'api', 'anthropic-messages.lazy.js')),
|
|
118
|
+
])
|
|
119
|
+
return { piAiAdapter, llm, piAi, catalog, completions, responses, anthropic }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function kitFromTree(mods: TreeModules): DshKit {
|
|
123
|
+
return {
|
|
124
|
+
source: 'dsh-tree',
|
|
125
|
+
PiAiAdapter: mods.piAiAdapter['PiAiAdapter'] as DshKit['PiAiAdapter'],
|
|
126
|
+
LlmError: mods.llm['LlmError'] as DshKit['LlmError'],
|
|
127
|
+
resolveRetryPolicy: mods.llm['resolveRetryPolicy'] as DshKit['resolveRetryPolicy'],
|
|
128
|
+
attributionHeaders: mods.llm['attributionHeaders'] as DshKit['attributionHeaders'],
|
|
129
|
+
normalizeApiKey: mods.llm['normalizeApiKey'] as DshKit['normalizeApiKey'],
|
|
130
|
+
assertUsableApiKey: mods.llm['assertUsableApiKey'] as DshKit['assertUsableApiKey'],
|
|
131
|
+
INVALID_CREDENTIAL_CODE: mods.llm['INVALID_CREDENTIAL_CODE'] as string,
|
|
132
|
+
createProvider: mods.piAi['createProvider'] as DshKit['createProvider'],
|
|
133
|
+
builtinProviders: mods.catalog['builtinProviders'] as DshKit['builtinProviders'],
|
|
134
|
+
getBuiltinProviders: mods.catalog['getBuiltinProviders'] as DshKit['getBuiltinProviders'],
|
|
135
|
+
getBuiltinModels: mods.catalog['getBuiltinModels'] as DshKit['getBuiltinModels'],
|
|
136
|
+
protocolFactories: {
|
|
137
|
+
'openai-completions': mods.completions['openAICompletionsApi'] as () => unknown,
|
|
138
|
+
'openai-responses': mods.responses['openAIResponsesApi'] as () => unknown,
|
|
139
|
+
'anthropic-messages': mods.anthropic['anthropicMessagesApi'] as () => unknown,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** vendored 兜底副本套件(导出供单测直接使用,免走 dsh 树解析)。 */
|
|
145
|
+
export function loadVendoredKit(): DshKit {
|
|
146
|
+
const kit: DshKit = {
|
|
147
|
+
source: 'vendored',
|
|
148
|
+
PiAiAdapter: vendoredPiAiAdapter.PiAiAdapter,
|
|
149
|
+
LlmError: vendoredLlm.LlmError,
|
|
150
|
+
resolveRetryPolicy: vendoredLlm.resolveRetryPolicy,
|
|
151
|
+
attributionHeaders: vendoredLlm.attributionHeaders,
|
|
152
|
+
normalizeApiKey: vendoredLlm.normalizeApiKey,
|
|
153
|
+
assertUsableApiKey: vendoredLlm.assertUsableApiKey,
|
|
154
|
+
INVALID_CREDENTIAL_CODE: vendoredLlm.INVALID_CREDENTIAL_CODE,
|
|
155
|
+
createProvider: vendoredPiAi.createProvider,
|
|
156
|
+
builtinProviders: vendoredCatalog.builtinProviders,
|
|
157
|
+
getBuiltinProviders: vendoredCatalog.getBuiltinProviders,
|
|
158
|
+
getBuiltinModels: vendoredCatalog.getBuiltinModels,
|
|
159
|
+
protocolFactories: {
|
|
160
|
+
'openai-completions': vendoredCompletions,
|
|
161
|
+
'openai-responses': vendoredResponses,
|
|
162
|
+
'anthropic-messages': vendoredAnthropic,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
assertKitShape(kit, 'vendored')
|
|
166
|
+
return kit
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 定位 dsh 安装树根:realpath(argv[1]) 向上查找;argv 异常时返回 undefined。 */
|
|
170
|
+
function dshTreeAnchor(): string | undefined {
|
|
171
|
+
const entry = process.argv[1]
|
|
172
|
+
if (entry === undefined) return undefined
|
|
173
|
+
try {
|
|
174
|
+
return findDshTreeRoot(dirname(realpathSync(entry)))
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 解析运行时套件:优先 dsh 安装树(自动跟随上游),失败回退 vendored 副本;
|
|
182
|
+
* 两者都过不了形状自检时抛错(调用方应记日志并放弃注册 route)。
|
|
183
|
+
* 返回的 diagnostics 记录回退原因,供配置卡片与日志展示。
|
|
184
|
+
*/
|
|
185
|
+
export async function resolveDshKit(): Promise<{ kit: DshKit; diagnostics: string[] }> {
|
|
186
|
+
const diagnostics: string[] = []
|
|
187
|
+
const anchor = dshTreeAnchor()
|
|
188
|
+
if (anchor !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
const kit = kitFromTree(await importTreeModules(anchor))
|
|
191
|
+
assertKitShape(kit, 'dsh-tree')
|
|
192
|
+
return { kit, diagnostics }
|
|
193
|
+
} catch (error) {
|
|
194
|
+
diagnostics.push(
|
|
195
|
+
`dsh 安装树套件不可用(${anchor}):${error instanceof Error ? error.message : String(error)};回退 vendored 副本`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
diagnostics.push('未能从 process.argv[1] 定位 dsh 安装树;回退 vendored 副本')
|
|
200
|
+
}
|
|
201
|
+
const kit = loadVendoredKit()
|
|
202
|
+
return { kit, diagnostics }
|
|
203
|
+
}
|