@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/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@dsh-plus/llm-pi",
3
+ "version": "0.1.0",
4
+ "description": "dsh-plus service+ui plugin: 基于 PiAiAdapter 的自定义 LLM 路由(全量 compat、模型继承、models.dev 目录兜底)",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": {
14
+ "default": "./lib/client.js"
15
+ },
16
+ "./src/*": "./src/*",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "lib",
21
+ "src"
22
+ ],
23
+ "dsh": {
24
+ "client": {
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-runtime",
27
+ "@deepseek-ai/dsh-client-locale"
28
+ ],
29
+ "platform": "web"
30
+ }
31
+ },
32
+ "dependencies": {
33
+ "@deepseek-ai/cordis": "4.0.1",
34
+ "@deepseek-ai/schemastery": "3.18.1",
35
+ "@deepseek-ai/dsh-settings": "0.1.0-rc.6",
36
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-credentials": "0.1.0-rc.6",
38
+ "@deepseek-ai/dsh-home-paths": "0.1.0-rc.6",
39
+ "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.6",
40
+ "@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6",
41
+ "@earendil-works/pi-ai": "0.82.1",
42
+ "https-proxy-agent": "^7.0.6"
43
+ },
44
+ "devDependencies": {
45
+ "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
46
+ "@types/react": "~18.3.1",
47
+ "react": "^18.2.0"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "license": "MIT",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/A-G-guy/dsh-plugins.git",
56
+ "directory": "packages/llm-pi"
57
+ },
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "watch": "tsdown --watch"
61
+ }
62
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * pi-ai 内置目录适配:继承解析的第一级(最高优先级)数据源。
3
+ * 内置条目带 pi 官方校正(compat、thinkingLevelMap、能力过滤),最可信。
4
+ * @module llm-pi/catalog/builtin
5
+ */
6
+ import type { DshKit } from '../resolve-dsh.ts'
7
+
8
+ /**
9
+ * 继承源可提供的模型字段(pi-ai Model 的可继承子集)。
10
+ * 全部可选:models.dev 兜底源只给得出其中一部分。
11
+ */
12
+ export interface ModelBase {
13
+ name?: string
14
+ api?: string
15
+ baseUrl?: string
16
+ input?: ('text' | 'image')[]
17
+ reasoning?: boolean
18
+ thinkingLevelMap?: Record<string, string | null | undefined>
19
+ compat?: Record<string, unknown>
20
+ contextWindow?: number
21
+ maxTokens?: number
22
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }
23
+ headers?: Record<string, string>
24
+ }
25
+
26
+ /** 内置 provider 的端点(provider 级 extends 的 baseURL 缺省值)。 */
27
+ export function builtinProviderBaseUrl(kit: DshKit, provider: string): string | undefined {
28
+ return kit.builtinProviders().find((p) => p.id === provider)?.baseUrl
29
+ }
30
+
31
+ /** 内置目录是否存在该 provider。 */
32
+ export function hasBuiltinProvider(kit: DshKit, provider: string): boolean {
33
+ return kit.getBuiltinProviders().includes(provider)
34
+ }
35
+
36
+ /** 内置 provider 的全部模型 id(UI extends 选择器用)。 */
37
+ export function builtinModelIds(kit: DshKit, provider: string): string[] {
38
+ if (!hasBuiltinProvider(kit, provider)) return []
39
+ return kit.getBuiltinModels(provider).map((m) => m.id)
40
+ }
41
+
42
+ /** 查单个内置模型为继承 base;未命中返回 undefined。 */
43
+ export function builtinModelBase(kit: DshKit, provider: string, modelId: string): ModelBase | undefined {
44
+ if (!hasBuiltinProvider(kit, provider)) return undefined
45
+ const model = kit.getBuiltinModels(provider).find((m) => m.id === modelId)
46
+ if (model === undefined) return undefined
47
+ return {
48
+ name: model.name,
49
+ api: model.api,
50
+ baseUrl: model.baseUrl,
51
+ input: [...model.input],
52
+ reasoning: model.reasoning,
53
+ ...(model.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: { ...model.thinkingLevelMap } }),
54
+ ...(model.compat === undefined ? {} : { compat: { ...(model.compat as Record<string, unknown>) } }),
55
+ contextWindow: model.contextWindow,
56
+ maxTokens: model.maxTokens,
57
+ cost: { ...model.cost },
58
+ ...(model.headers === undefined ? {} : { headers: { ...model.headers } }),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * provider 级 extends 的全量模型继承:route 不写 models 时,
64
+ * 以继承源 provider 的全部内置模型作为条目(每个条目 base 即其自身)。
65
+ */
66
+ export function inheritedCatalogEntries(
67
+ kit: DshKit,
68
+ provider: string,
69
+ ): { id: string; base: ModelBase }[] {
70
+ if (!hasBuiltinProvider(kit, provider)) return []
71
+ return kit.getBuiltinModels(provider).map((model) => ({
72
+ id: model.id,
73
+ base: builtinModelBase(kit, provider, model.id) ?? {},
74
+ }))
75
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * models.dev 兜底目录源:继承解析的第二级数据源。
3
+ *
4
+ * 只用于内置目录尚未收录的新模型/新供应商。数据是公开快照
5
+ *(默认 https://models.dev/api.json),**默认不自动拉取**(catalogRefreshHours=0);
6
+ * 拉取方式二选一:
7
+ * - 配置 catalogRefreshHours > 0:启动/过期后后台自动刷新;
8
+ * - 配置卡片「手动拉取」或 POST /catalog/refresh:立即拉取。
9
+ * 拉取可经 catalogProxy 代理(HTTP 代理,如 http://127.0.0.1:7890)。
10
+ * 成功落盘缓存(storages/dsh-plus-llm-pi/models-dev.json);任何失败都退化为
11
+ * "仅内置目录",绝不阻塞 route 注册。
12
+ *
13
+ * 保守原则:models.dev 数据未经 pi 官方校正(无 compat/thinkingLevelMap),
14
+ * 且模态声明不被采信——继承自本源的模型 input 一律走 text-only 兜底,
15
+ * 视觉等模态须用户在条目上显式声明(防 over-claiming 导致会话重复失败请求)。
16
+ * @module llm-pi/catalog/models-dev
17
+ */
18
+ import { request as httpRequest } from 'node:http'
19
+ import { request as httpsRequest } from 'node:https'
20
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
21
+ import { dirname } from 'node:path'
22
+
23
+ import HttpsProxyAgentModule from 'https-proxy-agent'
24
+
25
+ import type { ModelBase } from './builtin.ts'
26
+
27
+ /** 仅 https 目标走代理(http 目标直连;代理通常只提供 CONNECT 隧道)。 */
28
+ const { HttpsProxyAgent } = HttpsProxyAgentModule as unknown as {
29
+ HttpsProxyAgent: new (proxy: string) => unknown
30
+ }
31
+
32
+ export interface ModelsDevStatus {
33
+ fetchedAt: string | null
34
+ providers: number
35
+ models: number
36
+ error: string | null
37
+ }
38
+
39
+ interface ModelsDevModelEntry {
40
+ id?: string
41
+ name?: string
42
+ reasoning?: boolean
43
+ limit?: { context?: number; output?: number }
44
+ }
45
+
46
+ interface ModelsDevProviderEntry {
47
+ id?: string
48
+ name?: string
49
+ models?: Record<string, ModelsDevModelEntry>
50
+ }
51
+
52
+ type ModelsDevDocument = Record<string, ModelsDevProviderEntry>
53
+
54
+ interface CacheFile {
55
+ fetchedAt: string
56
+ data: ModelsDevDocument
57
+ }
58
+
59
+ const FETCH_TIMEOUT_MS = 20000
60
+ /** 目录文档体上限(api.json 全量约 1-2MB,放宽到 20MB 防未来膨胀)。 */
61
+ const MAX_RESPONSE_BYTES = 20 * 1024 * 1024
62
+
63
+ interface JsonResponse {
64
+ status: number
65
+ body: string
66
+ }
67
+
68
+ /**
69
+ * 极简 JSON GET(node:http(s) 实现):支持 HTTP 代理(仅 https 目标)与超时。
70
+ * 不跟随重定向(models.dev 直链无重定向;自定义端点需自行保证可直达)。
71
+ */
72
+ function fetchJson(url: string, proxy: string, timeoutMs: number): Promise<JsonResponse> {
73
+ return new Promise((resolve, reject) => {
74
+ const target = new URL(url)
75
+ const request = target.protocol === 'https:' ? httpsRequest : httpRequest
76
+ const agent = target.protocol === 'https:' && proxy.length > 0 ? new HttpsProxyAgent(proxy) : undefined
77
+ const req = request(
78
+ url,
79
+ { agent, timeout: timeoutMs, headers: { accept: 'application/json' } },
80
+ (response) => {
81
+ const chunks: Buffer[] = []
82
+ let size = 0
83
+ response.on('data', (chunk: Buffer) => {
84
+ size += chunk.length
85
+ if (size > MAX_RESPONSE_BYTES) {
86
+ req.destroy(new Error('响应超过 20MB 上限'))
87
+ return
88
+ }
89
+ chunks.push(chunk)
90
+ })
91
+ response.on('end', () => {
92
+ resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })
93
+ })
94
+ },
95
+ )
96
+ req.on('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms)`)))
97
+ req.on('error', reject)
98
+ req.end()
99
+ })
100
+ }
101
+
102
+ function isDocument(value: unknown): value is ModelsDevDocument {
103
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
104
+ }
105
+
106
+ /** 单个 models.dev 模型条目 → 继承 base(仅采信名称/容量/推理能力)。 */
107
+ function toModelBase(entry: ModelsDevModelEntry): ModelBase {
108
+ const base: ModelBase = {}
109
+ if (typeof entry.name === 'string' && entry.name.length > 0) base.name = entry.name
110
+ const context = entry.limit?.context
111
+ if (typeof context === 'number' && Number.isInteger(context) && context > 0) base.contextWindow = context
112
+ const output = entry.limit?.output
113
+ if (typeof output === 'number' && Number.isInteger(output) && output > 0) base.maxTokens = output
114
+ base.reasoning = entry.reasoning === true
115
+ return base
116
+ }
117
+
118
+ export class ModelsDevSource {
119
+ private readonly cacheFile: string
120
+ private url: string
121
+ private ttlHours: number
122
+ private proxy: string
123
+ private readonly log: (message: string) => void
124
+
125
+ private document: ModelsDevDocument | undefined
126
+ private fetchedAt: string | null = null
127
+ private lastError: string | null = null
128
+ private refreshing: Promise<void> | undefined
129
+
130
+ constructor(
131
+ cacheFile: string,
132
+ url: string,
133
+ ttlHours: number,
134
+ log: (message: string) => void,
135
+ proxy = '',
136
+ ) {
137
+ this.cacheFile = cacheFile
138
+ this.url = url
139
+ this.ttlHours = ttlHours
140
+ this.log = log
141
+ this.proxy = proxy
142
+ }
143
+
144
+ /** 配置变更时更新端点/TTL/代理并触发刷新(去抖由 refresh 的进行中复用承担)。 */
145
+ reconfigure(url: string, ttlHours: number, proxy: string): void {
146
+ if (url === this.url && ttlHours === this.ttlHours && proxy === this.proxy) return
147
+ this.url = url
148
+ this.ttlHours = ttlHours
149
+ this.proxy = proxy
150
+ if (ttlHours > 0 && this.isStale()) void this.refresh()
151
+ }
152
+
153
+ /** 是否已有可用数据(缓存或拉取成功);与自动拉取开关无关。 */
154
+ get enabled(): boolean {
155
+ return this.document !== undefined
156
+ }
157
+
158
+ /** 加载缓存,并在启用自动拉取且缓存过期时后台刷新;构造后调用一次,永不抛错。 */
159
+ async ensureLoaded(): Promise<void> {
160
+ this.loadCache()
161
+ if (this.ttlHours > 0 && this.isStale()) await this.refresh()
162
+ }
163
+
164
+ /** 强制刷新(手动拉取/配置变更触发);ttlHours=0 时同样生效(手动拉取不受自动开关限制)。 */
165
+ async refresh(): Promise<void> {
166
+ this.refreshing ??= this.doFetch().finally(() => {
167
+ this.refreshing = undefined
168
+ })
169
+ await this.refreshing
170
+ }
171
+
172
+ /** 查继承 base;未命中/未启用返回 undefined。 */
173
+ lookup(provider: string, modelId: string): ModelBase | undefined {
174
+ const entry = this.document?.[provider]?.models?.[modelId]
175
+ if (entry === undefined) return undefined
176
+ return toModelBase(entry)
177
+ }
178
+
179
+ /** 全部 provider id(UI extends 选择器用)。 */
180
+ providerIds(): string[] {
181
+ return Object.keys(this.document ?? {})
182
+ }
183
+
184
+ /** 某 provider 的模型 id 列表(UI extends 选择器用)。 */
185
+ modelIds(provider: string): string[] {
186
+ return Object.keys(this.document?.[provider]?.models ?? {})
187
+ }
188
+
189
+ status(): ModelsDevStatus {
190
+ const providers = Object.keys(this.document ?? {})
191
+ const models = providers.reduce(
192
+ (total, p) => total + Object.keys(this.document?.[p]?.models ?? {}).length,
193
+ 0,
194
+ )
195
+ return {
196
+ fetchedAt: this.fetchedAt,
197
+ providers: providers.length,
198
+ models,
199
+ error: this.lastError,
200
+ }
201
+ }
202
+
203
+ private isStale(): boolean {
204
+ if (this.fetchedAt === null) return true
205
+ const ageMs = Date.now() - Date.parse(this.fetchedAt)
206
+ return !Number.isFinite(ageMs) || ageMs > this.ttlHours * 3600_000
207
+ }
208
+
209
+ private loadCache(): void {
210
+ try {
211
+ const raw = JSON.parse(readFileSync(this.cacheFile, 'utf8')) as CacheFile
212
+ if (!isDocument(raw.data) || typeof raw.fetchedAt !== 'string') throw new Error('缓存形状非法')
213
+ this.document = raw.data
214
+ this.fetchedAt = raw.fetchedAt
215
+ } catch {
216
+ // 无缓存或缓存损坏:静默留给刷新补齐
217
+ }
218
+ }
219
+
220
+ private async doFetch(): Promise<void> {
221
+ try {
222
+ const response = await fetchJson(this.url, this.proxy, FETCH_TIMEOUT_MS)
223
+ if (response.status < 200 || response.status >= 300) {
224
+ throw new Error(`HTTP ${response.status}`)
225
+ }
226
+ const data: unknown = JSON.parse(response.body)
227
+ if (!isDocument(data)) throw new Error('响应不是 models.dev 目录文档')
228
+ this.document = data
229
+ this.fetchedAt = new Date().toISOString()
230
+ this.lastError = null
231
+ this.persistCache(data)
232
+ } catch (error) {
233
+ this.lastError = error instanceof Error ? error.message : String(error)
234
+ this.log(`models.dev 目录拉取失败(沿用缓存/仅内置目录):${this.lastError}`)
235
+ }
236
+ }
237
+
238
+ private persistCache(data: ModelsDevDocument): void {
239
+ try {
240
+ mkdirSync(dirname(this.cacheFile), { recursive: true })
241
+ const tmp = `${this.cacheFile}.tmp`
242
+ writeFileSync(tmp, JSON.stringify({ fetchedAt: this.fetchedAt, data }), 'utf8')
243
+ renameSync(tmp, this.cacheFile)
244
+ } catch (error) {
245
+ this.log(`models.dev 缓存写入失败:${error instanceof Error ? error.message : String(error)}`)
246
+ }
247
+ }
248
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 配置卡片数据通道:同源 fetch 调自建 webServer 路由(notify-email 同款模式;
3
+ * 官方 settings.* RPC 白名单硬编码不含第三方 namespace)。
4
+ * @module llm-pi/client/api
5
+ */
6
+
7
+ export interface WireModelsDevStatus {
8
+ fetchedAt: string | null
9
+ providers: number
10
+ error: string | null
11
+ }
12
+
13
+ export interface WireProvider {
14
+ extends?: string
15
+ displayName?: string
16
+ api?: string
17
+ baseURL?: string
18
+ apiKeyEnv?: string
19
+ headers?: Record<string, string>
20
+ compat?: Record<string, unknown>
21
+ defaultContextWindow?: number
22
+ defaultMaxTokens?: number
23
+ defaultInput?: string[]
24
+ reasoning?: string
25
+ thinkingBudgets?: { minimal: number; low: number; medium: number; high: number }
26
+ cacheRetention?: string
27
+ transport?: string
28
+ timeoutMs?: number
29
+ websocketConnectTimeoutMs?: number
30
+ streamIdleTimeoutMs?: number
31
+ retryPolicy?: unknown
32
+ models?: WireModel[]
33
+ }
34
+
35
+ export interface WireModel {
36
+ id: string
37
+ extends?: string
38
+ name?: string
39
+ contextWindow?: number
40
+ maxTokens?: number
41
+ input?: string[]
42
+ reasoningEfforts?: false | Record<string, string | null>
43
+ compat?: Record<string, unknown>
44
+ }
45
+
46
+ /** GET /config 返回(config.ts WireConfig)。 */
47
+ export interface WireConfig {
48
+ enabled: boolean
49
+ catalogUrl: string
50
+ catalogRefreshHours: number
51
+ catalogProxy: string
52
+ providers: Record<string, WireProvider>
53
+ writable: boolean
54
+ kitSource: string
55
+ modelsDevStatus: WireModelsDevStatus | null
56
+ }
57
+
58
+ /** PUT /config 提交形状(config.ts WirePatchInput;providers 全量替换)。 */
59
+ export interface WirePatchInput {
60
+ enabled?: boolean
61
+ catalogUrl?: string
62
+ catalogRefreshHours?: number
63
+ catalogProxy?: string
64
+ providers?: Record<string, WireProvider>
65
+ }
66
+
67
+ /** GET /catalog?provider=&source= 返回。 */
68
+ export interface CatalogResult {
69
+ providers: string[]
70
+ models: string[]
71
+ status?: WireModelsDevStatus
72
+ }
73
+
74
+ const ROUTE_CONFIG = '/dsh-plus/llm-pi/config'
75
+ const ROUTE_CATALOG = '/dsh-plus/llm-pi/catalog'
76
+
77
+ async function parse<T>(res: Response): Promise<T> {
78
+ const body = (await res.json()) as T & { error?: string }
79
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
80
+ return body
81
+ }
82
+
83
+ export async function fetchConfig(): Promise<WireConfig> {
84
+ return parse<WireConfig>(await fetch(ROUTE_CONFIG, { credentials: 'same-origin' }))
85
+ }
86
+
87
+ export async function saveConfig(patch: WirePatchInput): Promise<WireConfig> {
88
+ return parse<WireConfig>(
89
+ await fetch(ROUTE_CONFIG, {
90
+ method: 'PUT',
91
+ credentials: 'same-origin',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify(patch),
94
+ }),
95
+ )
96
+ }
97
+
98
+ /** 目录查询:provider 为空时只返回该源的 provider 列表。 */
99
+ export async function fetchCatalog(provider: string, source: 'builtin' | 'models-dev'): Promise<CatalogResult> {
100
+ const url = `${ROUTE_CATALOG}?provider=${encodeURIComponent(provider)}&source=${source}`
101
+ return parse<CatalogResult>(await fetch(url, { credentials: 'same-origin' }))
102
+ }
103
+
104
+ /** 手动拉取 models.dev 目录:POST /catalog/refresh → 最新快照状态。 */
105
+ export async function refreshCatalog(): Promise<{ status: WireModelsDevStatus }> {
106
+ return parse<{ status: WireModelsDevStatus }>(
107
+ await fetch(`${ROUTE_CATALOG}/refresh`, { method: 'POST', credentials: 'same-origin' }),
108
+ )
109
+ }