@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.
@@ -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
+ }