@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「LLM 路由」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
|
|
3
|
+
* 顶部:enabled / catalogUrl / catalogRefreshHours / 只读状态行(kitSource、
|
|
4
|
+
* modelsDevStatus)+ 保存(PUT 全量)与错误/成功提示;下方为 providers 路由
|
|
5
|
+
* 列表(新增/删除/字段编辑/compat/模型目录,见 views/)。
|
|
6
|
+
* 交互对齐官方卡片与 notify-email:折叠/展开、staged draft、未保存标记。
|
|
7
|
+
* @module llm-pi/client/card
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useMemo, useState, type ReactElement } from 'react'
|
|
10
|
+
|
|
11
|
+
import { fetchConfig, refreshCatalog, saveConfig, type WireConfig, type WireModelsDevStatus } from './api.ts'
|
|
12
|
+
import { draftFromWire, emptyProviderDraft, numTextOk, toPatch, type Draft, type ProviderDraft } from './draft.ts'
|
|
13
|
+
import { CheckRow, TextField } from './fields.tsx'
|
|
14
|
+
import { ProvidersSection } from './views/providers.tsx'
|
|
15
|
+
|
|
16
|
+
export interface CardProps {
|
|
17
|
+
t(key: string): string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Status {
|
|
21
|
+
kind: 'idle' | 'ok' | 'error'
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const IDLE_STATUS: Status = { kind: 'idle', text: '' }
|
|
26
|
+
|
|
27
|
+
function modelsDevText(status: WireModelsDevStatus | null, t: (key: string) => string): string {
|
|
28
|
+
if (status === null) return t('modelsDevEmpty')
|
|
29
|
+
if (status.error !== null) return `${t('modelsDevError')}${status.error}`
|
|
30
|
+
if (status.fetchedAt === null) return t('modelsDevEmpty')
|
|
31
|
+
return `${t('modelsDevStatusLine')}:${status.providers} 个 provider,快照 ${status.fetchedAt}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function LlmPiCard(props: CardProps): ReactElement | null {
|
|
35
|
+
const { t } = props
|
|
36
|
+
const [open, setOpen] = useState(false)
|
|
37
|
+
const [wire, setWire] = useState<WireConfig | null>(null)
|
|
38
|
+
const [draft, setDraft] = useState<Draft | null>(null)
|
|
39
|
+
const [epoch, setEpoch] = useState(0)
|
|
40
|
+
const [failed, setFailed] = useState(false)
|
|
41
|
+
const [saving, setSaving] = useState(false)
|
|
42
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
43
|
+
const [status, setStatus] = useState<Status>(IDLE_STATUS)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let alive = true
|
|
47
|
+
fetchConfig()
|
|
48
|
+
.then((loaded) => {
|
|
49
|
+
if (!alive) return
|
|
50
|
+
setWire(loaded)
|
|
51
|
+
setDraft(draftFromWire(loaded))
|
|
52
|
+
})
|
|
53
|
+
.catch(() => {
|
|
54
|
+
if (alive) setFailed(true)
|
|
55
|
+
})
|
|
56
|
+
return () => {
|
|
57
|
+
alive = false
|
|
58
|
+
}
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
const dirty = useMemo(
|
|
62
|
+
() =>
|
|
63
|
+
wire !== null &&
|
|
64
|
+
draft !== null &&
|
|
65
|
+
JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromWire(wire))),
|
|
66
|
+
[wire, draft],
|
|
67
|
+
)
|
|
68
|
+
const invalid = useMemo(() => {
|
|
69
|
+
if (draft === null) return false
|
|
70
|
+
return (
|
|
71
|
+
!numTextOk(draft.catalogRefreshHours) ||
|
|
72
|
+
Object.values(draft.providers).some((provider) =>
|
|
73
|
+
provider.models.some((model) => model.id.trim() === ''),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
}, [draft])
|
|
77
|
+
|
|
78
|
+
if (failed) return null
|
|
79
|
+
if (wire === null || draft === null) {
|
|
80
|
+
return <li className="lpc-card"><p className="lpc-readOnly">{t('loading')}</p></li>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const setProvider = (route: string, patch: Partial<ProviderDraft>): void => {
|
|
84
|
+
const current = draft.providers[route] ?? emptyProviderDraft()
|
|
85
|
+
setDraft({ ...draft, providers: { ...draft.providers, [route]: { ...current, ...patch } } })
|
|
86
|
+
setStatus(IDLE_STATUS)
|
|
87
|
+
}
|
|
88
|
+
const onAddRoute = (key: string): void => {
|
|
89
|
+
setDraft({ ...draft, providers: { ...draft.providers, [key]: emptyProviderDraft() } })
|
|
90
|
+
setStatus(IDLE_STATUS)
|
|
91
|
+
}
|
|
92
|
+
const onRemoveRoute = (route: string): void => {
|
|
93
|
+
const next = { ...draft.providers }
|
|
94
|
+
delete next[route]
|
|
95
|
+
setDraft({ ...draft, providers: next })
|
|
96
|
+
setStatus(IDLE_STATUS)
|
|
97
|
+
}
|
|
98
|
+
const onSave = (): void => {
|
|
99
|
+
setSaving(true)
|
|
100
|
+
saveConfig(toPatch(draft))
|
|
101
|
+
.then((saved) => {
|
|
102
|
+
setWire(saved)
|
|
103
|
+
setDraft(draftFromWire(saved))
|
|
104
|
+
setEpoch((value) => value + 1)
|
|
105
|
+
setStatus({ kind: 'ok', text: t('saveOk') })
|
|
106
|
+
})
|
|
107
|
+
.catch((error: unknown) => {
|
|
108
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
109
|
+
setStatus({ kind: 'error', text: `${t('saveFailed')}${message}` })
|
|
110
|
+
})
|
|
111
|
+
.finally(() => setSaving(false))
|
|
112
|
+
}
|
|
113
|
+
const onDiscard = (): void => {
|
|
114
|
+
setDraft(draftFromWire(wire))
|
|
115
|
+
setEpoch((value) => value + 1)
|
|
116
|
+
setStatus(IDLE_STATUS)
|
|
117
|
+
}
|
|
118
|
+
const onRefreshCatalog = (): void => {
|
|
119
|
+
setRefreshing(true)
|
|
120
|
+
refreshCatalog()
|
|
121
|
+
.then((result) => {
|
|
122
|
+
setWire({ ...wire, modelsDevStatus: result.status })
|
|
123
|
+
setStatus({ kind: 'ok', text: t('refreshOk') })
|
|
124
|
+
})
|
|
125
|
+
.catch((error: unknown) => {
|
|
126
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
127
|
+
setStatus({ kind: 'error', text: `${t('refreshFailed')}${message}` })
|
|
128
|
+
})
|
|
129
|
+
.finally(() => setRefreshing(false))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const disabled = !wire.writable
|
|
133
|
+
return (
|
|
134
|
+
<li className={`lpc-card${open ? ' lpc-cardOpen' : ''}`}>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="lpc-header"
|
|
138
|
+
aria-expanded={open}
|
|
139
|
+
aria-label={`${t(open ? 'collapse' : 'expand')}: ${t('title')}`}
|
|
140
|
+
onClick={() => setOpen(!open)}
|
|
141
|
+
>
|
|
142
|
+
<span className="lpc-headText">
|
|
143
|
+
<span className="lpc-name">{t('title')}</span>
|
|
144
|
+
<span className="lpc-description">{t('description')}</span>
|
|
145
|
+
</span>
|
|
146
|
+
{dirty ? <span className="lpc-pending">{t('unsaved')}</span> : null}
|
|
147
|
+
<span className={`lpc-chevron${open ? ' lpc-chevronOpen' : ''}`}>▾</span>
|
|
148
|
+
</button>
|
|
149
|
+
{open ? (
|
|
150
|
+
<div className="lpc-body">
|
|
151
|
+
{disabled ? <p className="lpc-readOnly" role="status">{t('readOnly')}</p> : null}
|
|
152
|
+
<CheckRow
|
|
153
|
+
id="lpc-enabled"
|
|
154
|
+
label={t('enabled')}
|
|
155
|
+
checked={draft.enabled}
|
|
156
|
+
disabled={disabled}
|
|
157
|
+
onEdit={(value) => {
|
|
158
|
+
setDraft({ ...draft, enabled: value })
|
|
159
|
+
setStatus(IDLE_STATUS)
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
<TextField
|
|
163
|
+
id="lpc-catalogUrl"
|
|
164
|
+
label={t('catalogUrl')}
|
|
165
|
+
hint={t('catalogUrlHint')}
|
|
166
|
+
value={draft.catalogUrl}
|
|
167
|
+
disabled={disabled}
|
|
168
|
+
onEdit={(value) => {
|
|
169
|
+
setDraft({ ...draft, catalogUrl: value })
|
|
170
|
+
setStatus(IDLE_STATUS)
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
<TextField
|
|
174
|
+
id="lpc-catalogRefresh"
|
|
175
|
+
label={t('catalogRefreshHours')}
|
|
176
|
+
hint={t('catalogRefreshHoursHint')}
|
|
177
|
+
value={draft.catalogRefreshHours}
|
|
178
|
+
numeric
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
invalid={!numTextOk(draft.catalogRefreshHours)}
|
|
181
|
+
invalidLabel={t('invalidNumber')}
|
|
182
|
+
onEdit={(value) => {
|
|
183
|
+
setDraft({ ...draft, catalogRefreshHours: value })
|
|
184
|
+
setStatus(IDLE_STATUS)
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
<TextField
|
|
188
|
+
id="lpc-catalogProxy"
|
|
189
|
+
label={t('catalogProxy')}
|
|
190
|
+
hint={t('catalogProxyHint')}
|
|
191
|
+
value={draft.catalogProxy}
|
|
192
|
+
disabled={disabled}
|
|
193
|
+
onEdit={(value) => {
|
|
194
|
+
setDraft({ ...draft, catalogProxy: value })
|
|
195
|
+
setStatus(IDLE_STATUS)
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
<p className="lpc-statusRow">{t('kitSource')}:{wire.kitSource}</p>
|
|
199
|
+
<div className="lpc-statusRow">
|
|
200
|
+
<span>{t('modelsDevStatus')}:{modelsDevText(wire.modelsDevStatus, t)}</span>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className="lpc-btn lpc-btnGhost lpc-btnSmall lpc-refreshBtn"
|
|
204
|
+
disabled={disabled || refreshing}
|
|
205
|
+
onClick={onRefreshCatalog}
|
|
206
|
+
>
|
|
207
|
+
{t(refreshing ? 'refreshingCatalog' : 'refreshCatalog')}
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
<ProvidersSection
|
|
211
|
+
providers={draft.providers}
|
|
212
|
+
epoch={epoch}
|
|
213
|
+
disabled={disabled}
|
|
214
|
+
t={t}
|
|
215
|
+
onAddRoute={onAddRoute}
|
|
216
|
+
onRemoveRoute={onRemoveRoute}
|
|
217
|
+
onPatchProvider={setProvider}
|
|
218
|
+
/>
|
|
219
|
+
<div className="lpc-footer">
|
|
220
|
+
{status.kind !== 'idle' ? (
|
|
221
|
+
<p className={`lpc-status${status.kind === 'error' ? ' lpc-statusError' : ''}`} role="status">
|
|
222
|
+
{status.text}
|
|
223
|
+
</p>
|
|
224
|
+
) : null}
|
|
225
|
+
<button type="button" className="lpc-btn lpc-btnGhost" disabled={!dirty || saving}
|
|
226
|
+
onClick={onDiscard}>
|
|
227
|
+
{t('discard')}
|
|
228
|
+
</button>
|
|
229
|
+
<button type="button" className="lpc-btn lpc-btnPrimary"
|
|
230
|
+
disabled={!dirty || invalid || saving || disabled}
|
|
231
|
+
onClick={onSave}>
|
|
232
|
+
{t(saving ? 'saving' : 'save')}
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
) : null}
|
|
237
|
+
</li>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半入口:注册 locale 字典 + 向 settings.plugin.item 插槽注册「LLM 路由」卡片。
|
|
3
|
+
* 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
|
|
4
|
+
* (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
|
|
5
|
+
*
|
|
6
|
+
* 类型说明:浏览器半只用到 slots/locale 的很窄一面,此处以最小本地接口声明,
|
|
7
|
+
* 避免为构建期类型引入整条官方 client 依赖树;运行时契约以官方
|
|
8
|
+
* dsh-client-ui-settings-plugins 的 settings.plugin.item 插槽为准。
|
|
9
|
+
* @module @dsh-plus/llm-pi/client
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
|
|
13
|
+
import { LlmPiCard } from './card.tsx'
|
|
14
|
+
import { en, NS, zh } from './i18n.ts'
|
|
15
|
+
import { injectStyle } from './styles.ts'
|
|
16
|
+
|
|
17
|
+
export const name = 'dsh-plus-llm-pi'
|
|
18
|
+
|
|
19
|
+
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
20
|
+
export const inject = ['slots', 'locale'] as const
|
|
21
|
+
|
|
22
|
+
interface SlotsLike {
|
|
23
|
+
inject(key: string, callback: () => unknown): unknown
|
|
24
|
+
register(options: Record<string, unknown>, component: unknown): () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface LocaleLike {
|
|
28
|
+
register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
|
|
29
|
+
bind(ns: string): (key: string) => string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ClientContext {
|
|
33
|
+
slots: SlotsLike
|
|
34
|
+
locale: LocaleLike
|
|
35
|
+
effect(execute: () => () => void, label?: string): unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function apply(ctx: Context): void {
|
|
39
|
+
const c = ctx as unknown as ClientContext
|
|
40
|
+
const tag = injectStyle()
|
|
41
|
+
c.effect(
|
|
42
|
+
() => () => {
|
|
43
|
+
tag?.remove()
|
|
44
|
+
},
|
|
45
|
+
'llm-pi: style',
|
|
46
|
+
)
|
|
47
|
+
c.effect(
|
|
48
|
+
() => c.locale.register(NS, { zh, en }),
|
|
49
|
+
'llm-pi: locale',
|
|
50
|
+
)
|
|
51
|
+
c.slots.inject('settings.plugin.item', () =>
|
|
52
|
+
c.slots.register(
|
|
53
|
+
{
|
|
54
|
+
name: 'settings.plugin.item',
|
|
55
|
+
id: 'llm-pi',
|
|
56
|
+
order: 110,
|
|
57
|
+
locale: NS,
|
|
58
|
+
inject: () => ({ t: c.locale.bind(NS) }),
|
|
59
|
+
},
|
|
60
|
+
LlmPiCard,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半内联常量:与服务端 packages/llm-pi/src/config.ts、compat.ts 逐字对齐。
|
|
3
|
+
* 浏览器半不能 import 服务端模块(tsdown 只打包 client 侧入口),
|
|
4
|
+
* 改动服务端这些常量时必须同步本文件。
|
|
5
|
+
* @module llm-pi/client/constants
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 协议枚举(来源:config.ts PROTOCOL_IDS)。 */
|
|
9
|
+
export const PROTOCOL_IDS = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const
|
|
10
|
+
|
|
11
|
+
/** thinking 档位(来源:config.ts THINKING_LEVELS)。 */
|
|
12
|
+
export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
13
|
+
|
|
14
|
+
/** 请求模态(来源:config.ts MODALITIES)。 */
|
|
15
|
+
export const MODALITIES = ['text', 'image'] as const
|
|
16
|
+
|
|
17
|
+
/** cacheRetention 枚举(来源:config.ts providerProfile.cacheRetention)。 */
|
|
18
|
+
export const CACHE_RETENTION_OPTIONS = ['none', 'short', 'long'] as const
|
|
19
|
+
|
|
20
|
+
/** transport 枚举(来源:config.ts providerProfile.transport)。 */
|
|
21
|
+
export const TRANSPORT_OPTIONS = ['sse', 'websocket', 'websocket-cached', 'auto'] as const
|
|
22
|
+
|
|
23
|
+
/** thinkingBudgets 档位键(来源:config.ts thinkingBudgets)。 */
|
|
24
|
+
export const BUDGET_KEYS = ['minimal', 'low', 'medium', 'high'] as const
|
|
25
|
+
|
|
26
|
+
type CompatValue = 'boolean' | 'object' | readonly string[]
|
|
27
|
+
|
|
28
|
+
/** 逐协议 compat 字段表(来源:compat.ts FIELDS_BY_PROTOCOL)。 */
|
|
29
|
+
const COMPAT_FIELDS: Record<string, Record<string, CompatValue>> = {
|
|
30
|
+
'openai-completions': {
|
|
31
|
+
supportsStore: 'boolean',
|
|
32
|
+
supportsDeveloperRole: 'boolean',
|
|
33
|
+
supportsReasoningEffort: 'boolean',
|
|
34
|
+
supportsUsageInStreaming: 'boolean',
|
|
35
|
+
maxTokensField: ['max_completion_tokens', 'max_tokens'],
|
|
36
|
+
requiresToolResultName: 'boolean',
|
|
37
|
+
requiresAssistantAfterToolResult: 'boolean',
|
|
38
|
+
requiresThinkingAsText: 'boolean',
|
|
39
|
+
requiresReasoningContentOnAssistantMessages: 'boolean',
|
|
40
|
+
thinkingFormat: [
|
|
41
|
+
'openai',
|
|
42
|
+
'openrouter',
|
|
43
|
+
'deepseek',
|
|
44
|
+
'together',
|
|
45
|
+
'zai',
|
|
46
|
+
'qwen',
|
|
47
|
+
'chat-template',
|
|
48
|
+
'qwen-chat-template',
|
|
49
|
+
'string-thinking',
|
|
50
|
+
'ant-ling',
|
|
51
|
+
],
|
|
52
|
+
chatTemplateKwargs: 'object',
|
|
53
|
+
openRouterRouting: 'object',
|
|
54
|
+
vercelGatewayRouting: 'object',
|
|
55
|
+
zaiToolStream: 'boolean',
|
|
56
|
+
supportsOpenAIGrammarTools: 'boolean',
|
|
57
|
+
supportsStrictMode: 'boolean',
|
|
58
|
+
cacheControlFormat: ['anthropic'],
|
|
59
|
+
sendSessionAffinityHeaders: 'boolean',
|
|
60
|
+
deferredToolsMode: ['kimi'],
|
|
61
|
+
sessionAffinityFormat: ['openai', 'openai-nosession', 'openrouter'],
|
|
62
|
+
supportsLongCacheRetention: 'boolean',
|
|
63
|
+
},
|
|
64
|
+
'openai-responses': {
|
|
65
|
+
supportsDeveloperRole: 'boolean',
|
|
66
|
+
sessionAffinityFormat: ['openai', 'openai-nosession', 'openrouter'],
|
|
67
|
+
supportsLongCacheRetention: 'boolean',
|
|
68
|
+
supportsStrictMode: 'boolean',
|
|
69
|
+
supportsOpenAIGrammarTools: 'boolean',
|
|
70
|
+
supportsToolSearch: 'boolean',
|
|
71
|
+
supportsExplicitPromptCacheMode: 'boolean',
|
|
72
|
+
},
|
|
73
|
+
'anthropic-messages': {
|
|
74
|
+
supportsEagerToolInputStreaming: 'boolean',
|
|
75
|
+
supportsLongCacheRetention: 'boolean',
|
|
76
|
+
sendSessionAffinityHeaders: 'boolean',
|
|
77
|
+
supportsCacheControlOnTools: 'boolean',
|
|
78
|
+
supportsTemperature: 'boolean',
|
|
79
|
+
forceAdaptiveThinking: 'boolean',
|
|
80
|
+
allowEmptySignature: 'boolean',
|
|
81
|
+
supportsStrictTools: 'boolean',
|
|
82
|
+
supportsToolReferences: 'boolean',
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 某协议的全部合法 compat 键(与服务端 compatFieldsOf 一致)。 */
|
|
87
|
+
export function compatFieldsOf(api: string): readonly string[] {
|
|
88
|
+
return Object.keys(COMPAT_FIELDS[api] ?? {})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 某协议某字段的取值约束(与服务端 compatFieldSpec 一致)。 */
|
|
92
|
+
export function compatFieldSpec(api: string, field: string): CompatValue | undefined {
|
|
93
|
+
return COMPAT_FIELDS[api]?.[field]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** api 未设置时的渲染回退组(最常见的协议;保存仍由后端按实际协议校验)。 */
|
|
97
|
+
export const COMPAT_FALLBACK_API = 'openai-completions'
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可编辑草稿模型与 WireConfig ↔ Draft 双向转换。
|
|
3
|
+
* 设计:数值字段用字符串承载('' = 未设置/不写入),多选枚举用布尔 map,
|
|
4
|
+
* 转换时剔除空值,保证 dirty 比较(toPatch 双侧)与保存形状稳定。
|
|
5
|
+
* @module llm-pi/client/draft
|
|
6
|
+
*/
|
|
7
|
+
import type { WireConfig, WireModel, WirePatchInput, WireProvider } from './api.ts'
|
|
8
|
+
|
|
9
|
+
export interface HeaderPair {
|
|
10
|
+
key: string
|
|
11
|
+
value: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InputDraft {
|
|
15
|
+
text: boolean
|
|
16
|
+
image: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BudgetDraft {
|
|
20
|
+
minimal: string
|
|
21
|
+
low: string
|
|
22
|
+
medium: string
|
|
23
|
+
high: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** reasoningEfforts:false = 非推理模型;levels 键为档位,'' = 未设置。 */
|
|
27
|
+
export interface ReasoningDraft {
|
|
28
|
+
nonReasoning: boolean
|
|
29
|
+
levels: Record<string, string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ModelDraft {
|
|
33
|
+
id: string
|
|
34
|
+
extends: string
|
|
35
|
+
name: string
|
|
36
|
+
contextWindow: string
|
|
37
|
+
maxTokens: string
|
|
38
|
+
input: InputDraft
|
|
39
|
+
reasoningEfforts: ReasoningDraft
|
|
40
|
+
compat: Record<string, unknown>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ProviderDraft {
|
|
44
|
+
extends: string
|
|
45
|
+
displayName: string
|
|
46
|
+
api: string
|
|
47
|
+
baseURL: string
|
|
48
|
+
apiKeyEnv: string
|
|
49
|
+
headers: HeaderPair[]
|
|
50
|
+
compat: Record<string, unknown>
|
|
51
|
+
defaultContextWindow: string
|
|
52
|
+
defaultMaxTokens: string
|
|
53
|
+
input: InputDraft
|
|
54
|
+
reasoning: string
|
|
55
|
+
thinkingBudgets: BudgetDraft
|
|
56
|
+
cacheRetention: string
|
|
57
|
+
transport: string
|
|
58
|
+
timeoutMs: string
|
|
59
|
+
websocketConnectTimeoutMs: string
|
|
60
|
+
streamIdleTimeoutMs: string
|
|
61
|
+
retryPolicy: unknown
|
|
62
|
+
models: ModelDraft[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Draft {
|
|
66
|
+
enabled: boolean
|
|
67
|
+
catalogUrl: string
|
|
68
|
+
catalogRefreshHours: string
|
|
69
|
+
catalogProxy: string
|
|
70
|
+
providers: Record<string, ProviderDraft>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function numToText(value: number | undefined): string {
|
|
74
|
+
return value === undefined ? '' : String(value)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 数字文本 → 数值;空串/非法返回 undefined(不写入)。 */
|
|
78
|
+
export function toNum(text: string): number | undefined {
|
|
79
|
+
const trimmed = text.trim()
|
|
80
|
+
if (trimmed === '') return undefined
|
|
81
|
+
const value = Number(trimmed)
|
|
82
|
+
return Number.isFinite(value) ? value : undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function numTextOk(text: string): boolean {
|
|
86
|
+
return text.trim() !== '' && Number.isFinite(Number(text.trim()))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function headersToPairs(headers: Record<string, string> | undefined): HeaderPair[] {
|
|
90
|
+
return Object.entries(headers ?? {}).map(([key, value]) => ({ key, value }))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function pairsToHeaders(pairs: HeaderPair[]): Record<string, string> | undefined {
|
|
94
|
+
const out: Record<string, string> = {}
|
|
95
|
+
for (const pair of pairs) {
|
|
96
|
+
if (pair.key.trim() !== '') out[pair.key.trim()] = pair.value
|
|
97
|
+
}
|
|
98
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function inputFromWire(list: string[] | undefined): InputDraft {
|
|
102
|
+
return { text: list?.includes('text') ?? false, image: list?.includes('image') ?? false }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function inputToWire(input: InputDraft): string[] | undefined {
|
|
106
|
+
const out: string[] = []
|
|
107
|
+
if (input.text) out.push('text')
|
|
108
|
+
if (input.image) out.push('image')
|
|
109
|
+
return out.length > 0 ? out : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function reasoningFromWire(value: false | Record<string, string | null> | undefined): ReasoningDraft {
|
|
113
|
+
if (value === false) return { nonReasoning: true, levels: {} }
|
|
114
|
+
const levels: Record<string, string> = {}
|
|
115
|
+
for (const [level, line] of Object.entries(value ?? {})) levels[level] = line ?? ''
|
|
116
|
+
return { nonReasoning: false, levels }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function reasoningToWire(value: ReasoningDraft): false | Record<string, string> | undefined {
|
|
120
|
+
if (value.nonReasoning) return false
|
|
121
|
+
const out: Record<string, string> = {}
|
|
122
|
+
for (const [level, line] of Object.entries(value.levels)) {
|
|
123
|
+
if (line.trim() !== '') out[level] = line.trim()
|
|
124
|
+
}
|
|
125
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function budgetFromWire(value: { minimal: number; low: number; medium: number; high: number } | undefined): BudgetDraft {
|
|
129
|
+
return {
|
|
130
|
+
minimal: numToText(value?.minimal),
|
|
131
|
+
low: numToText(value?.low),
|
|
132
|
+
medium: numToText(value?.medium),
|
|
133
|
+
high: numToText(value?.high),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function budgetToWire(value: BudgetDraft): { minimal: number; low: number; medium: number; high: number } | undefined {
|
|
138
|
+
const out: Record<string, number> = {}
|
|
139
|
+
for (const key of ['minimal', 'low', 'medium', 'high'] as const) {
|
|
140
|
+
const num = toNum(value[key])
|
|
141
|
+
if (num !== undefined) out[key] = num
|
|
142
|
+
}
|
|
143
|
+
return Object.keys(out).length > 0 ? (out as { minimal: number; low: number; medium: number; high: number }) : undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 剔除空值:undefined / '' / 空数组 / 空对象。 */
|
|
147
|
+
function omitEmpty(obj: Record<string, unknown>): Record<string, unknown> {
|
|
148
|
+
const out: Record<string, unknown> = {}
|
|
149
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
150
|
+
if (value === undefined || value === '') continue
|
|
151
|
+
if (Array.isArray(value) && value.length === 0) continue
|
|
152
|
+
if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) continue
|
|
153
|
+
out[key] = value
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function modelDraftFromWire(model: WireModel): ModelDraft {
|
|
159
|
+
return {
|
|
160
|
+
id: model.id,
|
|
161
|
+
extends: model.extends ?? '',
|
|
162
|
+
name: model.name ?? '',
|
|
163
|
+
contextWindow: numToText(model.contextWindow),
|
|
164
|
+
maxTokens: numToText(model.maxTokens),
|
|
165
|
+
input: inputFromWire(model.input),
|
|
166
|
+
reasoningEfforts: reasoningFromWire(model.reasoningEfforts),
|
|
167
|
+
compat: { ...(model.compat ?? {}) },
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function modelToWire(model: ModelDraft): WireModel {
|
|
172
|
+
const reasoningEfforts = reasoningToWire(model.reasoningEfforts)
|
|
173
|
+
return omitEmpty({
|
|
174
|
+
id: model.id.trim(),
|
|
175
|
+
extends: model.extends.trim(),
|
|
176
|
+
name: model.name.trim(),
|
|
177
|
+
contextWindow: toNum(model.contextWindow),
|
|
178
|
+
maxTokens: toNum(model.maxTokens),
|
|
179
|
+
input: inputToWire(model.input),
|
|
180
|
+
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
181
|
+
compat: model.compat,
|
|
182
|
+
}) as WireModel
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function providerDraftFromWire(provider: WireProvider): ProviderDraft {
|
|
186
|
+
return {
|
|
187
|
+
extends: provider.extends ?? '',
|
|
188
|
+
displayName: provider.displayName ?? '',
|
|
189
|
+
api: provider.api ?? '',
|
|
190
|
+
baseURL: provider.baseURL ?? '',
|
|
191
|
+
apiKeyEnv: provider.apiKeyEnv ?? '',
|
|
192
|
+
headers: headersToPairs(provider.headers),
|
|
193
|
+
compat: { ...(provider.compat ?? {}) },
|
|
194
|
+
defaultContextWindow: numToText(provider.defaultContextWindow),
|
|
195
|
+
defaultMaxTokens: numToText(provider.defaultMaxTokens),
|
|
196
|
+
input: inputFromWire(provider.defaultInput),
|
|
197
|
+
reasoning: provider.reasoning ?? '',
|
|
198
|
+
thinkingBudgets: budgetFromWire(provider.thinkingBudgets),
|
|
199
|
+
cacheRetention: provider.cacheRetention ?? '',
|
|
200
|
+
transport: provider.transport ?? '',
|
|
201
|
+
timeoutMs: numToText(provider.timeoutMs),
|
|
202
|
+
websocketConnectTimeoutMs: numToText(provider.websocketConnectTimeoutMs),
|
|
203
|
+
streamIdleTimeoutMs: numToText(provider.streamIdleTimeoutMs),
|
|
204
|
+
retryPolicy: provider.retryPolicy,
|
|
205
|
+
models: (provider.models ?? []).map(modelDraftFromWire),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function providerToWire(provider: ProviderDraft): WireProvider {
|
|
210
|
+
return omitEmpty({
|
|
211
|
+
extends: provider.extends.trim(),
|
|
212
|
+
displayName: provider.displayName.trim(),
|
|
213
|
+
api: provider.api,
|
|
214
|
+
baseURL: provider.baseURL.trim(),
|
|
215
|
+
apiKeyEnv: provider.apiKeyEnv.trim(),
|
|
216
|
+
headers: pairsToHeaders(provider.headers),
|
|
217
|
+
compat: provider.compat,
|
|
218
|
+
defaultContextWindow: toNum(provider.defaultContextWindow),
|
|
219
|
+
defaultMaxTokens: toNum(provider.defaultMaxTokens),
|
|
220
|
+
input: inputToWire(provider.input),
|
|
221
|
+
reasoning: provider.reasoning,
|
|
222
|
+
thinkingBudgets: budgetToWire(provider.thinkingBudgets),
|
|
223
|
+
cacheRetention: provider.cacheRetention,
|
|
224
|
+
transport: provider.transport,
|
|
225
|
+
timeoutMs: toNum(provider.timeoutMs),
|
|
226
|
+
websocketConnectTimeoutMs: toNum(provider.websocketConnectTimeoutMs),
|
|
227
|
+
streamIdleTimeoutMs: toNum(provider.streamIdleTimeoutMs),
|
|
228
|
+
retryPolicy: provider.retryPolicy,
|
|
229
|
+
models: provider.models.map(modelToWire),
|
|
230
|
+
}) as WireProvider
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function emptyProviderDraft(): ProviderDraft {
|
|
234
|
+
return {
|
|
235
|
+
extends: '',
|
|
236
|
+
displayName: '',
|
|
237
|
+
api: '',
|
|
238
|
+
baseURL: '',
|
|
239
|
+
apiKeyEnv: '',
|
|
240
|
+
headers: [],
|
|
241
|
+
compat: {},
|
|
242
|
+
defaultContextWindow: '',
|
|
243
|
+
defaultMaxTokens: '',
|
|
244
|
+
input: { text: false, image: false },
|
|
245
|
+
reasoning: '',
|
|
246
|
+
thinkingBudgets: { minimal: '', low: '', medium: '', high: '' },
|
|
247
|
+
cacheRetention: '',
|
|
248
|
+
transport: '',
|
|
249
|
+
timeoutMs: '',
|
|
250
|
+
websocketConnectTimeoutMs: '',
|
|
251
|
+
streamIdleTimeoutMs: '',
|
|
252
|
+
retryPolicy: undefined,
|
|
253
|
+
models: [],
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function emptyModelDraft(): ModelDraft {
|
|
258
|
+
return {
|
|
259
|
+
id: '',
|
|
260
|
+
extends: '',
|
|
261
|
+
name: '',
|
|
262
|
+
contextWindow: '',
|
|
263
|
+
maxTokens: '',
|
|
264
|
+
input: { text: false, image: false },
|
|
265
|
+
reasoningEfforts: { nonReasoning: false, levels: {} },
|
|
266
|
+
compat: {},
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function draftFromWire(wire: WireConfig): Draft {
|
|
271
|
+
return {
|
|
272
|
+
enabled: wire.enabled,
|
|
273
|
+
catalogUrl: wire.catalogUrl,
|
|
274
|
+
catalogRefreshHours: String(wire.catalogRefreshHours),
|
|
275
|
+
catalogProxy: wire.catalogProxy,
|
|
276
|
+
providers: Object.fromEntries(
|
|
277
|
+
Object.entries(wire.providers).map(([route, provider]) => [route, providerDraftFromWire(provider)]),
|
|
278
|
+
),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 提交补丁:完整配置对象,providers 全量替换;空值一律剔除。 */
|
|
283
|
+
export function toPatch(draft: Draft): WirePatchInput {
|
|
284
|
+
return {
|
|
285
|
+
enabled: draft.enabled,
|
|
286
|
+
catalogUrl: draft.catalogUrl.trim(),
|
|
287
|
+
catalogRefreshHours: toNum(draft.catalogRefreshHours),
|
|
288
|
+
catalogProxy: draft.catalogProxy.trim(),
|
|
289
|
+
providers: Object.fromEntries(
|
|
290
|
+
Object.entries(draft.providers).map(([route, provider]) => [route, providerToWire(provider)]),
|
|
291
|
+
),
|
|
292
|
+
}
|
|
293
|
+
}
|