@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/compat.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 逐协议 compat 字段表与校验。
3
+ *
4
+ * 背景:官方 llm-pi-ai 的配置路径只物化 thinkingFormat/supportsReasoningEffort
5
+ * 两个字段,多余键被静默丢弃;本插件开放 pi-ai 的全量 compat(字段表与
6
+ * pi-ai@0.82.1 types.d.ts:423-538 逐字段对齐),并在 settings 写入与
7
+ * profile 构建时校验——未知键/错类型值直接拒绝并给出合法键清单。
8
+ *
9
+ * pi-ai 侧消费语义:getCompat 逐字段 `??` 覆盖 detectCompat 的
10
+ * baseURL/名称猜测;undefined 视为未设置(无法显式清空检测值)。
11
+ * @module llm-pi/compat
12
+ */
13
+ import type { ProtocolId } from './config.ts'
14
+
15
+ type CompatValue = 'boolean' | 'object' | readonly string[]
16
+
17
+ /** openai-completions 的 21 个字段(pi-ai OpenAICompletionsCompat)。 */
18
+ const COMPLETIONS_FIELDS: Record<string, CompatValue> = {
19
+ supportsStore: 'boolean',
20
+ supportsDeveloperRole: 'boolean',
21
+ supportsReasoningEffort: 'boolean',
22
+ supportsUsageInStreaming: 'boolean',
23
+ maxTokensField: ['max_completion_tokens', 'max_tokens'],
24
+ requiresToolResultName: 'boolean',
25
+ requiresAssistantAfterToolResult: 'boolean',
26
+ requiresThinkingAsText: 'boolean',
27
+ requiresReasoningContentOnAssistantMessages: 'boolean',
28
+ thinkingFormat: [
29
+ 'openai',
30
+ 'openrouter',
31
+ 'deepseek',
32
+ 'together',
33
+ 'zai',
34
+ 'qwen',
35
+ 'chat-template',
36
+ 'qwen-chat-template',
37
+ 'string-thinking',
38
+ 'ant-ling',
39
+ ],
40
+ chatTemplateKwargs: 'object',
41
+ openRouterRouting: 'object',
42
+ vercelGatewayRouting: 'object',
43
+ zaiToolStream: 'boolean',
44
+ supportsOpenAIGrammarTools: 'boolean',
45
+ supportsStrictMode: 'boolean',
46
+ cacheControlFormat: ['anthropic'],
47
+ sendSessionAffinityHeaders: 'boolean',
48
+ deferredToolsMode: ['kimi'],
49
+ sessionAffinityFormat: ['openai', 'openai-nosession', 'openrouter'],
50
+ supportsLongCacheRetention: 'boolean',
51
+ }
52
+
53
+ /** openai-responses 的 7 个字段(pi-ai OpenAIResponsesCompat)。 */
54
+ const RESPONSES_FIELDS: Record<string, CompatValue> = {
55
+ supportsDeveloperRole: 'boolean',
56
+ sessionAffinityFormat: ['openai', 'openai-nosession', 'openrouter'],
57
+ supportsLongCacheRetention: 'boolean',
58
+ supportsStrictMode: 'boolean',
59
+ supportsOpenAIGrammarTools: 'boolean',
60
+ supportsToolSearch: 'boolean',
61
+ supportsExplicitPromptCacheMode: 'boolean',
62
+ }
63
+
64
+ /** anthropic-messages 的 9 个字段(pi-ai AnthropicMessagesCompat)。 */
65
+ const ANTHROPIC_FIELDS: Record<string, CompatValue> = {
66
+ supportsEagerToolInputStreaming: 'boolean',
67
+ supportsLongCacheRetention: 'boolean',
68
+ sendSessionAffinityHeaders: 'boolean',
69
+ supportsCacheControlOnTools: 'boolean',
70
+ supportsTemperature: 'boolean',
71
+ forceAdaptiveThinking: 'boolean',
72
+ allowEmptySignature: 'boolean',
73
+ supportsStrictTools: 'boolean',
74
+ supportsToolReferences: 'boolean',
75
+ }
76
+
77
+ const FIELDS_BY_PROTOCOL: Record<ProtocolId, Record<string, CompatValue>> = {
78
+ 'openai-completions': COMPLETIONS_FIELDS,
79
+ 'openai-responses': RESPONSES_FIELDS,
80
+ 'anthropic-messages': ANTHROPIC_FIELDS,
81
+ }
82
+
83
+ /** 某协议的全部合法 compat 键(UI 渲染字段组与校验共用)。 */
84
+ export function compatFieldsOf(api: ProtocolId): readonly string[] {
85
+ return Object.keys(FIELDS_BY_PROTOCOL[api])
86
+ }
87
+
88
+ /** 某协议某字段的取值约束(UI 渲染开关/下拉用)。 */
89
+ export function compatFieldSpec(api: ProtocolId, field: string): CompatValue | undefined {
90
+ return FIELDS_BY_PROTOCOL[api][field]
91
+ }
92
+
93
+ function checkValue(api: ProtocolId, field: string, spec: CompatValue, value: unknown, where: string): void {
94
+ if (spec === 'boolean') {
95
+ if (typeof value !== 'boolean') throw new Error(`${where}: compat.${field} 必须是布尔值`)
96
+ return
97
+ }
98
+ if (spec === 'object') {
99
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
100
+ throw new Error(`${where}: compat.${field} 必须是对象`)
101
+ }
102
+ return
103
+ }
104
+ if (typeof value !== 'string' || !spec.includes(value)) {
105
+ throw new Error(`${where}: compat.${field} 必须是 ${spec.map((v) => JSON.stringify(v)).join(' | ')} 之一`)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * 校验一份 compat 字典对指定协议合法:未知协议/未知键拒绝(对比官方的静默丢弃),
111
+ * 已知键校验值类型/枚举。undefined 值视为未设置,跳过(语义同 pi-ai 的 ??)。
112
+ */
113
+ export function validateCompat(api: string, compat: Record<string, unknown> | undefined, where: string): void {
114
+ if (compat === undefined) return
115
+ const fields = FIELDS_BY_PROTOCOL[api as ProtocolId]
116
+ if (fields === undefined) {
117
+ throw new Error(`${where}: 协议 ${JSON.stringify(api)} 无 compat 字段表(支持:${Object.keys(FIELDS_BY_PROTOCOL).join(', ')})`)
118
+ }
119
+ for (const [key, value] of Object.entries(compat)) {
120
+ const spec = fields[key]
121
+ if (spec === undefined) {
122
+ throw new Error(
123
+ `${where}: compat.${key} 不是 ${api} 协议的合法字段(合法字段:${Object.keys(fields).join(', ')})`,
124
+ )
125
+ }
126
+ if (value === undefined) continue
127
+ checkValue(api, key, spec, value, where)
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 逐字段合并 compat 层(后者覆盖前者),丢弃 undefined 值。
133
+ * 层序:继承源(仅同协议)→ route 级 → 模型级。
134
+ */
135
+ export function mergeCompat(
136
+ ...layers: (Record<string, unknown> | undefined)[]
137
+ ): Record<string, unknown> | undefined {
138
+ const merged: Record<string, unknown> = {}
139
+ for (const layer of layers) {
140
+ if (layer === undefined) continue
141
+ for (const [key, value] of Object.entries(layer)) {
142
+ if (value !== undefined) merged[key] = value
143
+ }
144
+ }
145
+ return Object.keys(merged).length > 0 ? merged : undefined
146
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * 配置 HTTP 通道:webui 配置卡片的后端(notify-email 同款模式)。
3
+ * 背景:官方 apiproxy 的 settings.* RPC 对 namespace 有硬编码白名单,第三方
4
+ * namespace 一律 settings-not-exposed;卡片数据走自建 webServer 同源路由,
5
+ * 写回经 ctx.settings.replace 整段覆盖用户层(providers dict 的删除语义
6
+ * 无法经深合并表达)。写入先过 settings 校验钩子(完整解析试跑),
7
+ * 非法配置在写入处拒绝并返回错误明细。
8
+ * @module llm-pi/config-api
9
+ */
10
+ import type { IncomingMessage, ServerResponse } from 'node:http'
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+
14
+ import { builtinModelIds } from './catalog/builtin.ts'
15
+ import { SETTINGS_NS, toWire, WirePatch, type WirePatchInput } from './config.ts'
16
+ import type { LlmPiRuntime } from './service.ts'
17
+
18
+ const ROUTE_CONFIG = '/dsh-plus/llm-pi/config'
19
+ const ROUTE_CATALOG = '/dsh-plus/llm-pi/catalog'
20
+ const MAX_BODY_BYTES = 256 * 1024
21
+
22
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
23
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
24
+ res.end(JSON.stringify(body))
25
+ }
26
+
27
+ function readBody(req: IncomingMessage): Promise<string> {
28
+ return new Promise((resolve, reject) => {
29
+ const chunks: Buffer[] = []
30
+ let size = 0
31
+ req.on('data', (chunk: Buffer) => {
32
+ size += chunk.length
33
+ if (size > MAX_BODY_BYTES) {
34
+ reject(new Error('request body too large'))
35
+ req.destroy()
36
+ return
37
+ }
38
+ chunks.push(chunk)
39
+ })
40
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
41
+ req.on('error', reject)
42
+ })
43
+ }
44
+
45
+ async function readPatch(req: IncomingMessage): Promise<WirePatchInput> {
46
+ const raw = await readBody(req)
47
+ let parsed: unknown
48
+ try {
49
+ parsed = JSON.parse(raw)
50
+ } catch {
51
+ throw new Error('request body is not valid JSON')
52
+ }
53
+ return WirePatch(parsed) as WirePatchInput
54
+ }
55
+
56
+ function wireOf(runtime: LlmPiRuntime, writable: boolean) {
57
+ // 状态始终上报(含未拉取/失败),由卡片区分文案;不再用 enabled 做 null 开关。
58
+ return toWire(runtime.currentConfig(), writable, runtime.kitInfo().source, runtime.modelsDev.status())
59
+ }
60
+
61
+ async function handleConfig(
62
+ ctx: Context,
63
+ runtime: LlmPiRuntime,
64
+ req: IncomingMessage,
65
+ res: ServerResponse,
66
+ ): Promise<void> {
67
+ const settings = ctx.get('settings')
68
+ if (req.method === 'GET') {
69
+ sendJson(res, 200, wireOf(runtime, settings !== undefined))
70
+ return
71
+ }
72
+ if (req.method !== 'PUT') {
73
+ sendJson(res, 405, { error: 'method not allowed' })
74
+ return
75
+ }
76
+ if (settings === undefined) {
77
+ sendJson(res, 503, { error: 'settings provider 不可用,无法在线保存;请编辑 settings.yaml' })
78
+ return
79
+ }
80
+ const patch = await readPatch(req)
81
+ await settings.replace(SETTINGS_NS, patch)
82
+ sendJson(res, 200, wireOf(runtime, true))
83
+ }
84
+
85
+ /** 目录查询/手动拉取:GET ?provider=&source= → 该源模型 id 列表;POST /refresh → 立即拉取。 */
86
+ function handleCatalog(runtime: LlmPiRuntime, req: IncomingMessage, res: ServerResponse): void {
87
+ if (req.method === 'POST' && req.url?.endsWith('/refresh')) {
88
+ void runtime.modelsDev.refresh().then(() => {
89
+ sendJson(res, 200, { status: runtime.modelsDev.status() })
90
+ })
91
+ return
92
+ }
93
+ const url = new URL(req.url ?? '', 'http://localhost')
94
+ const provider = url.searchParams.get('provider') ?? ''
95
+ const source = url.searchParams.get('source') ?? 'builtin'
96
+ if (source === 'models-dev') {
97
+ sendJson(res, 200, {
98
+ providers: runtime.modelsDev.providerIds(),
99
+ models: provider.length > 0 ? runtime.modelsDev.modelIds(provider) : [],
100
+ status: runtime.modelsDev.status(),
101
+ })
102
+ return
103
+ }
104
+ sendJson(res, 200, {
105
+ providers: runtime.kit.getBuiltinProviders(),
106
+ models: provider.length > 0 ? builtinModelIds(runtime.kit, provider) : [],
107
+ })
108
+ }
109
+
110
+ /** 注册配置读写与目录查询路由(webServer 缺失时由调用方保证不调用)。 */
111
+ export function registerConfigApi(ctx: Context, runtime: LlmPiRuntime): void {
112
+ const logger = ctx.logger('llm-pi')
113
+ const guard = (handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> | void) => {
114
+ return async (req: IncomingMessage, res: ServerResponse) => {
115
+ try {
116
+ await handler(req, res)
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error)
119
+ logger.warn(`config api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
120
+ if (!res.headersSent) sendJson(res, 400, { error: message })
121
+ else res.end()
122
+ }
123
+ }
124
+ }
125
+ ctx.webServer.register({
126
+ kind: 'exact',
127
+ path: ROUTE_CONFIG,
128
+ handler: guard((req, res) => handleConfig(ctx, runtime, req, res)),
129
+ })
130
+ ctx.webServer.register({
131
+ kind: 'prefix',
132
+ path: ROUTE_CATALOG,
133
+ handler: guard((req, res) => handleCatalog(runtime, req, res)),
134
+ })
135
+ }
package/src/config.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 配置单一事实源:cordis 行级 Config(组合默认值)与 settings namespace
3
+ *(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)共用同一
4
+ * schemastery schema。无密钥字段(apiKeyEnv 是凭据引用名而非密钥本身)。
5
+ *
6
+ * 与官方 llm-pi-ai 的差异:
7
+ * - compat 是开放 dict,物化时按协议校验(见 compat.ts),写入即拒绝未知键;
8
+ * - provider/model 均支持 extends 继承(见 inherit.ts)。
9
+ * @module llm-pi/config
10
+ */
11
+ import z from '@deepseek-ai/schemastery'
12
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
13
+
14
+ /** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
15
+ export const SETTINGS_NS = settingsNamespace('dsh-plus-llm-pi')
16
+
17
+ /** 本插件可为手写 route 提供的协议实现(与官方 PROTOCOLS 表一致)。 */
18
+ export const PROTOCOL_IDS = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const
19
+ export type ProtocolId = (typeof PROTOCOL_IDS)[number]
20
+
21
+ /** pi-ai 思考档位,升级序。 */
22
+ export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
23
+
24
+ export const MODALITIES = ['text', 'image'] as const
25
+
26
+ export const DEFAULT_CONTEXT_WINDOW = 262144
27
+ export const DEFAULT_MAX_TOKENS = 32768
28
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300000
29
+ /** dsh-timeout 的定时器上限(与官方 MAX_TIMER_DELAY_MS 对齐)。 */
30
+ export const MAX_TIMER_DELAY_MS = 2 ** 31 - 1
31
+
32
+ /** 键为可选档位,值为线协议拼写;仅 off 可留空(支持但不发送参数)。 */
33
+ const reasoningEfforts = z.dict(z.union([z.string(), z.const(null)]), z.union(THINKING_LEVELS))
34
+
35
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number]
36
+ export type Modality = (typeof MODALITIES)[number]
37
+ export type ReasoningEfforts = Partial<Record<ThinkingLevel, string | null>>
38
+
39
+ /** 单个模型条目:id 必填,其余字段缺省即继承。 */
40
+ export interface ModelEntryConfig {
41
+ id: string
42
+ extends?: string
43
+ name?: string
44
+ contextWindow?: number
45
+ maxTokens?: number
46
+ input?: Modality[]
47
+ reasoningEfforts?: false | ReasoningEfforts
48
+ compat?: Record<string, unknown>
49
+ }
50
+
51
+ /** 单个 provider route 配置(providers 字典的值)。 */
52
+ export interface ProviderProfileConfig {
53
+ extends?: string
54
+ displayName?: string
55
+ api?: ProtocolId
56
+ baseURL?: string
57
+ apiKeyEnv?: string
58
+ headers?: Record<string, string>
59
+ compat?: Record<string, unknown>
60
+ defaultContextWindow?: number
61
+ defaultMaxTokens?: number
62
+ defaultInput?: Modality[]
63
+ reasoning?: ThinkingLevel
64
+ thinkingBudgets?: { minimal: number; low: number; medium: number; high: number }
65
+ cacheRetention?: 'none' | 'short' | 'long'
66
+ transport?: 'sse' | 'websocket' | 'websocket-cached' | 'auto'
67
+ timeoutMs?: number
68
+ websocketConnectTimeoutMs?: number
69
+ streamIdleTimeoutMs?: number
70
+ retryPolicy?: unknown
71
+ models?: ModelEntryConfig[]
72
+ }
73
+
74
+ /** 插件配置根。 */
75
+ export interface LlmPiConfig {
76
+ enabled: boolean
77
+ catalogUrl: string
78
+ catalogRefreshHours: number
79
+ catalogProxy: string
80
+ providers: Record<string, ProviderProfileConfig>
81
+ }
82
+
83
+ const thinkingBudgets = z.object({
84
+ minimal: z.number(),
85
+ low: z.number(),
86
+ medium: z.number(),
87
+ high: z.number(),
88
+ })
89
+
90
+ /**
91
+ * compat 开放字典:承载 pi-ai 的全量 compat 字段(按协议分型,
92
+ * 字段集与值校验见 compat.ts,在 settings 写入与 profile 构建时执行)。
93
+ * schema 层不收紧,是因为字段集取决于本条目的 api,schema 无法表达条件分型。
94
+ */
95
+ const compatDict = z.dict(z.any())
96
+
97
+ const modelEntry = z.object({
98
+ id: z.string().required().description('模型 id(发送给 provider 的标识)'),
99
+ extends: z
100
+ .string()
101
+ .description('继承源:"provider/model" 或裸 model id(随 provider 级 extends 源);缺省先查内置目录同名模型'),
102
+ name: z.string().description('选择器显示名;缺省继承内置目录名,再退化为 id'),
103
+ contextWindow: z.number().step(1).min(1).description('上下文容量(覆盖继承值)'),
104
+ maxTokens: z.number().step(1).min(1).description('输出能力上限;显式配置同时成为无 cap 请求的默认 cap'),
105
+ input: z.array(z.union(MODALITIES)).description('请求模态;缺省继承内置目录,再退化 route defaultInput'),
106
+ reasoningEfforts: z
107
+ .union([z.const(false), reasoningEfforts])
108
+ .description('可选 reasoning 档位:false=非推理模型;dict=档位→线值映射;缺省继承内置目录能力'),
109
+ compat: compatDict.description('模型级 compat(字段级合并,压过 route 级与继承值)'),
110
+ })
111
+
112
+ const providerProfile = z.object({
113
+ extends: z
114
+ .string()
115
+ .description('provider 级继承:内置 provider id,提供 api/baseURL 默认值与模型 extends 的缺省查找源'),
116
+ displayName: z.string().description('选择器显示名;缺省为 route 键'),
117
+ api: z.union(PROTOCOL_IDS).description('线协议;缺省逐模型取继承值的 api,全部一致时作为 route 协议'),
118
+ baseURL: z.string().description('端点;缺省继承 extends 源 provider 的端点'),
119
+ apiKeyEnv: z.string().role('credential-ref').description('凭据引用名(凭据服务/环境变量)'),
120
+ headers: z.dict(z.string()).description('provider 请求头(Harness 署名头保留名优先)'),
121
+ compat: compatDict.description('route 级 compat 默认(逐模型按字段生效)'),
122
+ defaultContextWindow: z.number().step(1).min(1).description('模型与继承源都未标注时的上下文容量兜底'),
123
+ defaultMaxTokens: z.number().step(1).min(1).description('模型与继承源都未标注时的输出能力兜底'),
124
+ defaultInput: z
125
+ .array(z.union(MODALITIES))
126
+ .description('模型与继承源都未声明时的模态兜底(不可为空)')
127
+ .default(['text']),
128
+ reasoning: z.union(THINKING_LEVELS).description('provider 默认 reasoning 档位'),
129
+ thinkingBudgets: thinkingBudgets.description('支持 token 预算的推理 provider 的档位预算'),
130
+ cacheRetention: z.union(['none', 'short', 'long']).description('提示缓存保留偏好'),
131
+ transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']).description('流式传输偏好'),
132
+ timeoutMs: z.natural().description('HTTP/provider 超时毫秒'),
133
+ websocketConnectTimeoutMs: z.natural().description('WebSocket 连接超时毫秒'),
134
+ streamIdleTimeoutMs: z
135
+ .number()
136
+ .min(Number.MIN_VALUE)
137
+ .max(MAX_TIMER_DELAY_MS)
138
+ .description('单支流式读取的最大空闲间隔毫秒'),
139
+ retryPolicy: z.any().description('provider 重试策略(dsh-llm RetryPolicy 形状,构建期校验)'),
140
+ models: z.array(modelEntry).description('本 route 的模型目录;缺省且 provider 有 extends 时继承该源全部模型'),
141
+ })
142
+
143
+ export const Config: z<LlmPiConfig> = z.object({
144
+ enabled: z.boolean().description('总开关(关闭则不注册任何 route)').default(true),
145
+ catalogUrl: z.string().description('models.dev 目录数据端点').default('https://models.dev/api.json'),
146
+ catalogRefreshHours: z
147
+ .number()
148
+ .description('models.dev 自动拉取间隔小时数;0 = 不自动拉取(可手动拉取或读已有缓存)')
149
+ .default(0),
150
+ catalogProxy: z
151
+ .string()
152
+ .description('拉取 models.dev 目录时的 HTTP 代理地址(如 http://127.0.0.1:7890);留空直连')
153
+ .default(''),
154
+ providers: z.dict(providerProfile).description('provider 路由表,键即 route 名').default({}),
155
+ })
156
+
157
+ /** 配置卡片读取用的传输对象:配置无密钥字段,原样传输;附运行期元信息。 */
158
+ export interface WireConfig {
159
+ enabled: boolean
160
+ catalogUrl: string
161
+ catalogRefreshHours: number
162
+ catalogProxy: string
163
+ providers: Record<string, ProviderProfileConfig>
164
+ /** 是否存在可写的 settings provider(决定卡片是否允许编辑)。 */
165
+ writable: boolean
166
+ /** 模块解析来源与自检结果(dsh 树 / vendored 兜底)。 */
167
+ kitSource: string
168
+ /** models.dev 快照状态(fetchedAt/模型数/错误),供卡片展示。 */
169
+ modelsDevStatus: { fetchedAt: string | null; providers: number; models: number; error: string | null } | null
170
+ }
171
+
172
+ export function toWire(
173
+ cfg: LlmPiConfig,
174
+ writable: boolean,
175
+ kitSource: string,
176
+ modelsDevStatus: WireConfig['modelsDevStatus'],
177
+ ): WireConfig {
178
+ return {
179
+ enabled: cfg.enabled,
180
+ catalogUrl: cfg.catalogUrl,
181
+ catalogRefreshHours: cfg.catalogRefreshHours,
182
+ catalogProxy: cfg.catalogProxy ?? '',
183
+ providers: cfg.providers ?? {},
184
+ writable,
185
+ kitSource,
186
+ modelsDevStatus,
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 配置卡片写回:卡片总是提交完整配置对象(含 providers 全量),
192
+ * 后端经 settings.replace 整段覆盖用户层——providers dict 的删除语义
193
+ * 无法经深合并表达,整段替换是唯一正确语义。
194
+ */
195
+ /** 卡片提交的形状:字段全可选(无默认值),只携带用户编辑过的字段。 */
196
+ export interface WirePatchInput {
197
+ enabled?: boolean
198
+ catalogUrl?: string
199
+ catalogRefreshHours?: number
200
+ catalogProxy?: string
201
+ providers?: Record<string, ProviderProfileConfig>
202
+ }
203
+
204
+ export const WirePatch: z<WirePatchInput> = z.object({
205
+ enabled: z.boolean(),
206
+ catalogUrl: z.string(),
207
+ catalogRefreshHours: z.number(),
208
+ catalogProxy: z.string(),
209
+ providers: z.dict(providerProfile),
210
+ })
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 模型发现:配置面"拉取可用模型"动作的后端(对齐官方 discoverModels 语义)。
3
+ *
4
+ * 与官方的差异:本插件 route 不是内置目录 id,目录直答改为"provider 级
5
+ * extends 的内置源目录直答";其余(手写 route 仅 openai 系协议走
6
+ * GET {baseURL}/models、4MB 上限、署名头)与官方一致。结果不落盘。
7
+ * @module llm-pi/discovery
8
+ */
9
+ import { hasBuiltinProvider, builtinModelIds } from './catalog/builtin.ts'
10
+ import type { ProviderProfileConfig } from './config.ts'
11
+ import type { DshKit } from './resolve-dsh.ts'
12
+
13
+ const LISTABLE_PROTOCOLS = new Set(['openai-completions', 'openai-responses'])
14
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
15
+
16
+ export interface DiscoveryRequest {
17
+ provider?: string
18
+ baseURL?: string
19
+ api?: string
20
+ apiKey?: string
21
+ signal?: AbortSignal
22
+ }
23
+
24
+ export interface DiscoveryEntry {
25
+ id: string
26
+ name?: string
27
+ contextWindow?: number
28
+ maxTokens?: number
29
+ }
30
+
31
+ export interface DiscoveryDeps {
32
+ kit: DshKit
33
+ /** 当前生效的原始配置 providers 表(发现面对的是草稿/配置,不是物化产物)。 */
34
+ configProviders: () => Record<string, ProviderProfileConfig | undefined>
35
+ storedApiKey: (provider: string | undefined) => Promise<string | undefined>
36
+ }
37
+
38
+ /** 读取有界响应体:声明超长或累计超长都拒绝(对齐官方 readBounded)。 */
39
+ async function readBounded(kit: DshKit, response: Response, url: string): Promise<string> {
40
+ const oversized = () => new kit.LlmError(`${url} 响应超过 ${MAX_RESPONSE_BYTES} 字节`, 'DISCOVERY_FAILED')
41
+ const declared = Number(response.headers.get('content-length') ?? NaN)
42
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
43
+ await response.body?.cancel()
44
+ throw oversized()
45
+ }
46
+ if (response.body === null) return ''
47
+ const reader = response.body.getReader()
48
+ const chunks: Uint8Array[] = []
49
+ let total = 0
50
+ try {
51
+ for (;;) {
52
+ const { done, value } = await reader.read()
53
+ if (done) break
54
+ total += value.byteLength
55
+ if (total > MAX_RESPONSE_BYTES) throw oversized()
56
+ chunks.push(value)
57
+ }
58
+ } finally {
59
+ await reader.cancel().catch(() => {})
60
+ }
61
+ const body = new Uint8Array(total)
62
+ let offset = 0
63
+ for (const chunk of chunks) {
64
+ body.set(chunk, offset)
65
+ offset += chunk.byteLength
66
+ }
67
+ return new TextDecoder().decode(body)
68
+ }
69
+
70
+ /** 解析 OpenAI 兼容模型清单;坏行跳过而非整表失败(对齐官方 readListing)。 */
71
+ function readListing(kit: DshKit, body: unknown): DiscoveryEntry[] {
72
+ const data = (body as { data?: unknown })?.data
73
+ if (!Array.isArray(data)) {
74
+ throw new kit.LlmError('端点的模型清单缺少 "data" 数组;请手工录入模型', 'DISCOVERY_FAILED')
75
+ }
76
+ const models: DiscoveryEntry[] = []
77
+ for (const raw of data) {
78
+ const entry = raw as Record<string, unknown>
79
+ if (typeof entry?.['id'] !== 'string' || entry['id'].length === 0) continue
80
+ const out: DiscoveryEntry = { id: entry['id'] }
81
+ const name = entry['name'] ?? entry['display_name']
82
+ if (typeof name === 'string' && name.length > 0) out.name = name
83
+ for (const [key, field] of [
84
+ ['context_window', 'contextWindow'],
85
+ ['context_length', 'contextWindow'],
86
+ ['max_output_tokens', 'maxTokens'],
87
+ ['max_tokens', 'maxTokens'],
88
+ ] as const) {
89
+ const value = entry[key]
90
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0 && out[field] === undefined) {
91
+ out[field] = value
92
+ }
93
+ }
94
+ models.push(out)
95
+ }
96
+ return models
97
+ }
98
+
99
+ /** 内置目录直答(route 配了 provider 级 extends 时)。 */
100
+ function catalogAnswer(kit: DshKit, source: string): DiscoveryEntry[] {
101
+ return builtinModelIds(kit, source).map((id) => {
102
+ const models = kit.getBuiltinModels(source)
103
+ const model = models.find((m) => m.id === id)
104
+ return {
105
+ id,
106
+ ...(model?.name === undefined ? {} : { name: model.name }),
107
+ ...(model?.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }),
108
+ ...(model?.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }),
109
+ }
110
+ })
111
+ }
112
+
113
+ /**
114
+ * 回答"该 provider 可服务哪些模型":extends 内置源零网络直答;
115
+ * 否则仅 openai 系协议走 GET {baseURL}/models;其余协议明确不支持。
116
+ */
117
+ export async function discoverModels(request: DiscoveryRequest, deps: DiscoveryDeps): Promise<DiscoveryEntry[]> {
118
+ const { kit } = deps
119
+ const route: ProviderProfileConfig | undefined =
120
+ request.provider === undefined ? undefined : deps.configProviders()[request.provider]
121
+ if (route?.extends !== undefined && hasBuiltinProvider(kit, route.extends)) {
122
+ return catalogAnswer(kit, route.extends)
123
+ }
124
+ const baseURL = request.baseURL ?? route?.baseURL
125
+ if (baseURL === undefined || baseURL.length === 0) {
126
+ throw new kit.LlmError(
127
+ `route ${JSON.stringify(request.provider ?? '')} 未配 baseURL 且 extends 源无内置目录;无法探测模型清单`,
128
+ 'DISCOVERY_FAILED',
129
+ )
130
+ }
131
+ const api = request.api ?? route?.api ?? 'openai-completions'
132
+ if (!LISTABLE_PROTOCOLS.has(api)) {
133
+ throw new kit.LlmError(`协议 "${api}" 无可读取的模型清单端点;请手工录入模型`, 'DISCOVERY_UNSUPPORTED')
134
+ }
135
+ const url = `${baseURL.replace(/\/+$/, '')}/models`
136
+ const supplied = request.apiKey ?? (await deps.storedApiKey(request.provider))
137
+ let authorization: string | undefined
138
+ if (supplied !== undefined) {
139
+ const checked = kit.normalizeApiKey(supplied)
140
+ if (!checked.ok) {
141
+ throw new kit.LlmError(
142
+ checked.reason === 'empty' ? 'API key 为空;请在 Models 页配置或留空以匿名探测' : 'API key 含有 HTTP 头无法携带的字符',
143
+ kit.INVALID_CREDENTIAL_CODE,
144
+ )
145
+ }
146
+ authorization = `Bearer ${checked.value}`
147
+ }
148
+ let response: Response
149
+ try {
150
+ response = await fetch(url, {
151
+ method: 'GET',
152
+ headers: {
153
+ accept: 'application/json',
154
+ ...(authorization === undefined ? {} : { authorization }),
155
+ ...kit.attributionHeaders(),
156
+ },
157
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
158
+ })
159
+ } catch (error) {
160
+ if (request.signal?.aborted) throw new kit.LlmError('模型发现被调用方中止', 'ABORTED', { cause: error })
161
+ throw new kit.LlmError(`无法连接 ${url}`, 'DISCOVERY_FAILED', { cause: error })
162
+ }
163
+ if (!response.ok) {
164
+ throw new kit.LlmError(
165
+ `${url} 返回 ${response.status}${response.status === 401 || response.status === 403 ? ';请检查 API key' : ''}`,
166
+ 'DISCOVERY_FAILED',
167
+ )
168
+ }
169
+ const text = await readBounded(kit, response, url)
170
+ try {
171
+ return readListing(kit, JSON.parse(text))
172
+ } catch (error) {
173
+ if (error instanceof kit.LlmError) throw error
174
+ throw new kit.LlmError(`${url} 未返回 JSON`, 'DISCOVERY_FAILED', { cause: error })
175
+ }
176
+ }