pi-model-manager 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 +661 -0
- package/NOTICE +8 -0
- package/README.en.md +206 -0
- package/README.md +210 -0
- package/atomic-write.ts +100 -0
- package/builtin-model-catalog.ts +92 -0
- package/claude-code-compat.ts +56 -0
- package/common.ts +66 -0
- package/compat-settings.ts +25 -0
- package/config-value-reference.ts +51 -0
- package/configuration-persistence.ts +72 -0
- package/header-profile-mutations.ts +46 -0
- package/index.ts +82 -0
- package/local-proxy-service.ts +313 -0
- package/model-mutations.ts +210 -0
- package/models-json-manager.ts +238 -0
- package/models-json-mutations.ts +90 -0
- package/models-json-sync.ts +386 -0
- package/openai-responses-payload.ts +80 -0
- package/openai-service-tier.ts +36 -0
- package/package.json +74 -0
- package/presets/builtin-client-headers.ts +23 -0
- package/presets/client-headers.ts +126 -0
- package/presets/providers.ts +80 -0
- package/presets/thinking.ts +81 -0
- package/provider-registrar.ts +281 -0
- package/request-pipeline.ts +88 -0
- package/rescue.ts +69 -0
- package/runtime-base-url.ts +54 -0
- package/state-cache.ts +55 -0
- package/state-document.ts +569 -0
- package/state-metadata-store.ts +455 -0
- package/state-store.ts +238 -0
- package/tui/dashboard.ts +376 -0
- package/tui/editor-model.ts +339 -0
- package/tui/editor-provider.ts +219 -0
- package/tui/header-profiles-panel.ts +307 -0
- package/tui/model-list-fetch.ts +259 -0
- package/tui/model-picker.ts +266 -0
- package/tui/models-json-panel.ts +269 -0
- package/tui/persistent-menu.ts +349 -0
- package/tui/ui-helpers.ts +289 -0
- package/types.ts +165 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
// state-document.ts
|
|
2
|
+
//
|
|
3
|
+
// 纯函数模块:draft ↔ stored 转换、validate、upsert/delete、显示用助手。
|
|
4
|
+
// 不做 IO,不依赖 ctx/pi;测试时直接喂 fixture 即可。
|
|
5
|
+
//
|
|
6
|
+
// 设计原则:
|
|
7
|
+
// - 所有 upsert/delete 都返回新 StateDocument(不可变,深拷贝再改)
|
|
8
|
+
// - validate 返回 string[](空数组 = 通过)
|
|
9
|
+
// - builtInProviderIds 由调用方传入,校验“接入 ID 不与 pi 内置 id 冲突”
|
|
10
|
+
// (由调用方从 catalog 边界传入,避免本纯函数模块依赖 Pi 运行时 API)
|
|
11
|
+
|
|
12
|
+
import { cloneJson, cloneStringRecord, hasStringRecordEntries, trimOrFallback } from "./common.ts";
|
|
13
|
+
import {
|
|
14
|
+
getConfigValueEnvVarNames,
|
|
15
|
+
getSingleConfigValueEnvVarName,
|
|
16
|
+
isCommandConfigValue,
|
|
17
|
+
} from "./config-value-reference.ts";
|
|
18
|
+
import { findPresetForApi } from "./presets/providers.ts";
|
|
19
|
+
import { normalizeThinkingLevelMap } from "./presets/thinking.ts";
|
|
20
|
+
import type {
|
|
21
|
+
ApiKind,
|
|
22
|
+
CompatSettings,
|
|
23
|
+
ModelDraft,
|
|
24
|
+
ModelInputKind,
|
|
25
|
+
ProviderDraft,
|
|
26
|
+
RequestHeaderProfileDraft,
|
|
27
|
+
StateDocument,
|
|
28
|
+
StoredModel,
|
|
29
|
+
StoredProvider,
|
|
30
|
+
StoredRequestHeaderProfile,
|
|
31
|
+
} from "./types.ts";
|
|
32
|
+
import { DEFAULT_PROVIDER_HTTP_PROXY_URL, ZERO_COST } from "./types.ts";
|
|
33
|
+
|
|
34
|
+
// ========== 小工具 ==========
|
|
35
|
+
|
|
36
|
+
const API_KINDS: ApiKind[] = ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"];
|
|
37
|
+
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
38
|
+
const MAX_PROVIDER_ID_LENGTH = 48;
|
|
39
|
+
const RESERVED_REQUEST_HEADER_PROFILE_IDS = new Set(["claude-code", "codex-cli", "claude-code-live", "codex-cli-live"]);
|
|
40
|
+
const SENSITIVE_REQUEST_HEADER_NAMES = new Set(["authorization", "api-key", "x-api-key", "cookie", "set-cookie", "proxy-authorization"]);
|
|
41
|
+
|
|
42
|
+
export function isApiKind(value: unknown): value is ApiKind {
|
|
43
|
+
return typeof value === "string" && API_KINDS.includes(value as ApiKind);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getModelFullId(providerId: string, modelId: string): string {
|
|
47
|
+
return `${providerId}/${modelId}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getProviderDisplayName(providerId: string, provider: StoredProvider): string {
|
|
51
|
+
return trimOrFallback(provider.name, providerId.replace(/^custom-/, ""));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatInputKinds(inputKinds: ModelInputKind[]): string {
|
|
55
|
+
return inputKinds.includes("image") ? "text,image" : "text";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getApiKeyEnvVarName(apiKey: string): string | undefined {
|
|
59
|
+
return getSingleConfigValueEnvVarName(apiKey);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getAuthStatusText(apiKey: string | undefined): string {
|
|
63
|
+
if (!apiKey) return "no apiKey";
|
|
64
|
+
if (isCommandConfigValue(apiKey)) return "command apiKey";
|
|
65
|
+
const envVarNames = getConfigValueEnvVarNames(apiKey);
|
|
66
|
+
if (envVarNames.length === 0) return "literal apiKey";
|
|
67
|
+
const missingNames = envVarNames.filter((name) => !process.env[name]);
|
|
68
|
+
if (missingNames.length > 0) return `env missing: ${missingNames.join(", ")}`;
|
|
69
|
+
const singleEnvVarName = getApiKeyEnvVarName(apiKey);
|
|
70
|
+
return singleEnvVarName ? `env ${singleEnvVarName}` : `env template: ${envVarNames.join(", ")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
// ========== draft 工厂 ==========
|
|
75
|
+
|
|
76
|
+
export function createProviderDraft(api: ApiKind = "openai-responses"): ProviderDraft {
|
|
77
|
+
const preset = findPresetForApi(api);
|
|
78
|
+
return {
|
|
79
|
+
providerId: "",
|
|
80
|
+
providerName: preset.defaultProviderName,
|
|
81
|
+
api,
|
|
82
|
+
baseUrl: preset.baseUrl,
|
|
83
|
+
apiKey: preset.apiKey,
|
|
84
|
+
authHeader: preset.authHeader,
|
|
85
|
+
clientHeaderProfile: "recommended",
|
|
86
|
+
customClientHeaders: {},
|
|
87
|
+
httpProxyEnabled: false,
|
|
88
|
+
httpProxyUrl: DEFAULT_PROVIDER_HTTP_PROXY_URL,
|
|
89
|
+
selectedIndex: 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createProviderDraftFromStored(providerId: string, stored: StoredProvider): ProviderDraft {
|
|
94
|
+
const api = isApiKind(stored.api) ? stored.api : "openai-completions";
|
|
95
|
+
const preset = findPresetForApi(api);
|
|
96
|
+
return {
|
|
97
|
+
providerId,
|
|
98
|
+
providerName: stored.name ?? (providerId.replace(/^custom-/, "") || preset.defaultProviderName),
|
|
99
|
+
api,
|
|
100
|
+
baseUrl: stored.baseUrl ?? preset.baseUrl,
|
|
101
|
+
apiKey: stored.apiKey ?? "",
|
|
102
|
+
authHeader: stored.authHeader ?? preset.authHeader,
|
|
103
|
+
clientHeaderProfile: stored.clientHeaderProfile ?? "recommended",
|
|
104
|
+
requestHeaderProfileId: stored.requestHeaderProfileId,
|
|
105
|
+
customClientHeaders: cloneStringRecord(stored.customClientHeaders),
|
|
106
|
+
httpProxyEnabled: stored.httpProxyEnabled ?? false,
|
|
107
|
+
httpProxyUrl: stored.httpProxyUrl?.trim() || DEFAULT_PROVIDER_HTTP_PROXY_URL,
|
|
108
|
+
selectedIndex: 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createModelDraftFromProvider(providerDraft: ProviderDraft): ModelDraft {
|
|
113
|
+
const preset = findPresetForApi(providerDraft.api);
|
|
114
|
+
return {
|
|
115
|
+
providerId: providerDraft.providerId,
|
|
116
|
+
providerName: providerDraft.providerName,
|
|
117
|
+
api: providerDraft.api,
|
|
118
|
+
baseUrl: providerDraft.baseUrl,
|
|
119
|
+
apiKey: providerDraft.apiKey,
|
|
120
|
+
authHeader: providerDraft.authHeader,
|
|
121
|
+
clientHeaderProfile: providerDraft.clientHeaderProfile,
|
|
122
|
+
requestHeaderProfileId: providerDraft.requestHeaderProfileId,
|
|
123
|
+
customClientHeaders: cloneStringRecord(providerDraft.customClientHeaders),
|
|
124
|
+
httpProxyEnabled: providerDraft.httpProxyEnabled,
|
|
125
|
+
httpProxyUrl: providerDraft.httpProxyUrl,
|
|
126
|
+
modelId: "",
|
|
127
|
+
modelName: "",
|
|
128
|
+
// 新建模型默认开启视觉输入;不支持图片的模型可在编辑器里关闭。
|
|
129
|
+
inputKinds: ["text", "image"],
|
|
130
|
+
reasoningMode: preset.defaultReasoning ? "enabled" : "disabled",
|
|
131
|
+
anthropicThinkingProtocol: providerDraft.api === "anthropic-messages" ? "adaptive" : undefined,
|
|
132
|
+
contextWindow: preset.contextWindow,
|
|
133
|
+
maxTokens: preset.maxTokens,
|
|
134
|
+
openAIServiceTier: undefined,
|
|
135
|
+
selectedIndex: 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createModelDraftForStoredProvider(providerId: string, stored: StoredProvider): ModelDraft {
|
|
140
|
+
return createModelDraftFromProvider(createProviderDraftFromStored(providerId, stored));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createModelDraftFromStoredModel(
|
|
144
|
+
providerId: string,
|
|
145
|
+
stored: StoredProvider,
|
|
146
|
+
model: StoredModel,
|
|
147
|
+
): ModelDraft {
|
|
148
|
+
const providerApi = isApiKind(stored.api) ? stored.api : "openai-completions";
|
|
149
|
+
const effectiveApi = isApiKind(model.api) ? model.api : providerApi;
|
|
150
|
+
const preset = findPresetForApi(effectiveApi);
|
|
151
|
+
const reasoning = model.reasoning ?? preset.defaultReasoning;
|
|
152
|
+
const modelAdaptiveOverride = model.compat?.forceAdaptiveThinking;
|
|
153
|
+
const usesAdaptiveThinking = modelAdaptiveOverride === true
|
|
154
|
+
|| (modelAdaptiveOverride === undefined && stored.compat?.forceAdaptiveThinking === true);
|
|
155
|
+
return {
|
|
156
|
+
providerId,
|
|
157
|
+
providerName: stored.name ?? (providerId.replace(/^custom-/, "") || preset.defaultProviderName),
|
|
158
|
+
api: effectiveApi,
|
|
159
|
+
baseUrl: model.baseUrl ?? stored.baseUrl ?? preset.baseUrl,
|
|
160
|
+
apiKey: stored.apiKey ?? "",
|
|
161
|
+
authHeader: stored.authHeader ?? preset.authHeader,
|
|
162
|
+
clientHeaderProfile: stored.clientHeaderProfile ?? "recommended",
|
|
163
|
+
requestHeaderProfileId: stored.requestHeaderProfileId,
|
|
164
|
+
customClientHeaders: cloneStringRecord(stored.customClientHeaders),
|
|
165
|
+
httpProxyEnabled: stored.httpProxyEnabled ?? false,
|
|
166
|
+
httpProxyUrl: stored.httpProxyUrl?.trim() || DEFAULT_PROVIDER_HTTP_PROXY_URL,
|
|
167
|
+
modelId: model.id,
|
|
168
|
+
modelName: model.name ?? "",
|
|
169
|
+
inputKinds: [...(model.input ?? preset.inputKinds)],
|
|
170
|
+
reasoningMode: reasoning ? "enabled" : "disabled",
|
|
171
|
+
anthropicThinkingProtocol: effectiveApi === "anthropic-messages"
|
|
172
|
+
? (usesAdaptiveThinking ? "adaptive" : "legacy")
|
|
173
|
+
: undefined,
|
|
174
|
+
contextWindow: model.contextWindow ?? preset.contextWindow,
|
|
175
|
+
maxTokens: model.maxTokens ?? preset.maxTokens,
|
|
176
|
+
openAIServiceTier: effectiveApi === "openai-responses" ? model.openAIServiceTier : undefined,
|
|
177
|
+
selectedIndex: 0,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ========== validate ==========
|
|
182
|
+
|
|
183
|
+
function validateUrl(url: string, label: string, errors: string[]): void {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = new URL(url);
|
|
186
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
187
|
+
errors.push(`${label} 必须使用 http 或 https`);
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
errors.push(`${label} 不是有效 URL`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateHttpProxyUrl(url: string, errors: string[]): void {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = new URL(url);
|
|
197
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") errors.push("代理地址目前只支持 http:// 或 https:// 代理");
|
|
198
|
+
if (!parsed.hostname) errors.push("代理地址必须包含主机名");
|
|
199
|
+
} catch {
|
|
200
|
+
errors.push("代理地址不是有效 URL");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function validateProviderDraft(
|
|
205
|
+
draft: ProviderDraft,
|
|
206
|
+
document: StateDocument,
|
|
207
|
+
builtInProviderIds: ReadonlySet<string>,
|
|
208
|
+
oldProviderId?: string,
|
|
209
|
+
): string[] {
|
|
210
|
+
const errors: string[] = [];
|
|
211
|
+
const id = draft.providerId.trim();
|
|
212
|
+
|
|
213
|
+
if (!id) errors.push("接入 ID 不能为空");
|
|
214
|
+
else if (!ID_PATTERN.test(id)) errors.push("接入 ID 只能包含字母、数字、点、下划线和连字符");
|
|
215
|
+
else if (id.length > MAX_PROVIDER_ID_LENGTH) errors.push(`接入 ID 最多 ${MAX_PROVIDER_ID_LENGTH} 个字符`);
|
|
216
|
+
|
|
217
|
+
if (id && oldProviderId !== id && document.providers[id]) {
|
|
218
|
+
errors.push(`接入 ID 已存在:${id}`);
|
|
219
|
+
}
|
|
220
|
+
if (id && builtInProviderIds.has(id)) {
|
|
221
|
+
errors.push(`接入 ID 与 pi 内置接入冲突:${id}(请改用其它 ID)`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!draft.baseUrl.trim()) errors.push("Base URL 不能为空");
|
|
225
|
+
else validateUrl(draft.baseUrl.trim(), "Base URL", errors);
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if (draft.clientHeaderProfile === "custom") {
|
|
229
|
+
const profileId = draft.requestHeaderProfileId?.trim();
|
|
230
|
+
if (!profileId && !hasStringRecordEntries(draft.customClientHeaders)) errors.push("请选择一个自定义请求头");
|
|
231
|
+
else if (profileId && !document.requestHeaderProfiles[profileId]) errors.push(`自定义请求头不存在:${profileId}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (draft.httpProxyEnabled) {
|
|
235
|
+
const proxyUrl = draft.httpProxyUrl.trim() || DEFAULT_PROVIDER_HTTP_PROXY_URL;
|
|
236
|
+
validateHttpProxyUrl(proxyUrl, errors);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return errors;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function validateModelDraft(
|
|
243
|
+
draft: ModelDraft,
|
|
244
|
+
document: StateDocument,
|
|
245
|
+
replacedModelId?: string,
|
|
246
|
+
): string[] {
|
|
247
|
+
const errors: string[] = [];
|
|
248
|
+
if (!draft.providerId.trim()) errors.push("接入 ID 不能为空");
|
|
249
|
+
if (!draft.modelId.trim()) errors.push("模型 ID 不能为空");
|
|
250
|
+
|
|
251
|
+
const provider = document.providers[draft.providerId];
|
|
252
|
+
if (!provider) errors.push(`接入配置不存在:${draft.providerId}`);
|
|
253
|
+
|
|
254
|
+
if (!draft.baseUrl.trim()) errors.push("Base URL 不能为空");
|
|
255
|
+
else validateUrl(draft.baseUrl.trim(), "Base URL", errors);
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if (!Number.isInteger(draft.contextWindow) || draft.contextWindow <= 0)
|
|
259
|
+
errors.push("上下文窗口必须是正整数");
|
|
260
|
+
if (!Number.isInteger(draft.maxTokens) || draft.maxTokens <= 0)
|
|
261
|
+
errors.push("最大输出必须是正整数");
|
|
262
|
+
|
|
263
|
+
const duplicate = (provider?.models ?? []).find((m) => m.id === draft.modelId.trim());
|
|
264
|
+
if (duplicate && duplicate.id !== replacedModelId)
|
|
265
|
+
errors.push(`模型已存在:${draft.modelId.trim()}`);
|
|
266
|
+
|
|
267
|
+
return errors;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function getProviderChangeWarnings(
|
|
271
|
+
current: StoredProvider | undefined,
|
|
272
|
+
draft: ProviderDraft,
|
|
273
|
+
): string[] {
|
|
274
|
+
if (!current) return [];
|
|
275
|
+
const affected = current.models?.length ?? 0;
|
|
276
|
+
if (affected === 0) return [];
|
|
277
|
+
const changes: string[] = [];
|
|
278
|
+
if ((current.baseUrl ?? "") !== draft.baseUrl.trim()) changes.push("Base URL");
|
|
279
|
+
if ((current.apiKey ?? "") !== draft.apiKey.trim()) changes.push("API key");
|
|
280
|
+
if ((current.api ?? "") !== draft.api) changes.push("API 协议");
|
|
281
|
+
if ((current.authHeader ?? false) !== draft.authHeader) changes.push("认证头");
|
|
282
|
+
if ((current.clientHeaderProfile ?? "recommended") !== draft.clientHeaderProfile
|
|
283
|
+
|| (current.requestHeaderProfileId ?? "") !== (draft.requestHeaderProfileId ?? ""))
|
|
284
|
+
changes.push("请求头");
|
|
285
|
+
if ((current.httpProxyEnabled ?? false) !== draft.httpProxyEnabled
|
|
286
|
+
|| (current.httpProxyUrl?.trim() || DEFAULT_PROVIDER_HTTP_PROXY_URL) !== (draft.httpProxyUrl.trim() || DEFAULT_PROVIDER_HTTP_PROXY_URL)) {
|
|
287
|
+
changes.push("本机代理");
|
|
288
|
+
}
|
|
289
|
+
if (changes.length === 0) return [];
|
|
290
|
+
return [`将修改接入级配置:${changes.join("、")},会影响该接入下 ${affected} 个模型。`];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ========== build:draft → stored ==========
|
|
294
|
+
|
|
295
|
+
function stripLegacyModelHeaderProfile(model: StoredModel): StoredModel {
|
|
296
|
+
const {
|
|
297
|
+
clientHeaderProfile: _clientHeaderProfile,
|
|
298
|
+
requestHeaderProfileId: _requestHeaderProfileId,
|
|
299
|
+
customClientHeaders: _customClientHeaders,
|
|
300
|
+
...rest
|
|
301
|
+
} = model;
|
|
302
|
+
return rest;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function keepModelFieldsSupportedByApi(model: StoredModel, providerApi: ApiKind, providerApiChanged: boolean): StoredModel {
|
|
306
|
+
const normalized = stripLegacyModelHeaderProfile(model);
|
|
307
|
+
const effectiveApi = isApiKind(normalized.api) ? normalized.api : providerApi;
|
|
308
|
+
const next = effectiveApi === "openai-responses"
|
|
309
|
+
? normalized
|
|
310
|
+
: (() => {
|
|
311
|
+
const { openAIServiceTier: _openAIServiceTier, ...rest } = normalized;
|
|
312
|
+
return rest;
|
|
313
|
+
})();
|
|
314
|
+
const thinkingLevelMap = normalizeThinkingLevelMap(
|
|
315
|
+
effectiveApi,
|
|
316
|
+
next.reasoning,
|
|
317
|
+
providerApiChanged && !normalized.api ? undefined : next.thinkingLevelMap,
|
|
318
|
+
);
|
|
319
|
+
if (thinkingLevelMap) next.thinkingLevelMap = thinkingLevelMap;
|
|
320
|
+
else delete next.thinkingLevelMap;
|
|
321
|
+
return next;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function buildProviderFromDraft(
|
|
325
|
+
current: StoredProvider | undefined,
|
|
326
|
+
draft: ProviderDraft,
|
|
327
|
+
): StoredProvider {
|
|
328
|
+
const apiChanged = Boolean(current && current.api !== draft.api);
|
|
329
|
+
const next: StoredProvider = {
|
|
330
|
+
...(current ?? { models: [] }),
|
|
331
|
+
name: trimOrFallback(draft.providerName, draft.providerId.trim()),
|
|
332
|
+
api: draft.api,
|
|
333
|
+
baseUrl: draft.baseUrl.trim(),
|
|
334
|
+
authHeader: draft.authHeader,
|
|
335
|
+
clientHeaderProfile: draft.clientHeaderProfile,
|
|
336
|
+
models: (current?.models ?? []).map((model) => keepModelFieldsSupportedByApi(model, draft.api, apiChanged)),
|
|
337
|
+
};
|
|
338
|
+
const apiKey = draft.apiKey.trim();
|
|
339
|
+
if (apiKey) next.apiKey = apiKey;
|
|
340
|
+
else delete next.apiKey;
|
|
341
|
+
const httpProxyUrl = draft.httpProxyUrl.trim() || DEFAULT_PROVIDER_HTTP_PROXY_URL;
|
|
342
|
+
if (draft.httpProxyEnabled) next.httpProxyEnabled = true;
|
|
343
|
+
else delete next.httpProxyEnabled;
|
|
344
|
+
if (draft.httpProxyEnabled || httpProxyUrl !== DEFAULT_PROVIDER_HTTP_PROXY_URL) next.httpProxyUrl = httpProxyUrl;
|
|
345
|
+
else delete next.httpProxyUrl;
|
|
346
|
+
if (draft.clientHeaderProfile === "custom" && draft.requestHeaderProfileId?.trim()) {
|
|
347
|
+
next.requestHeaderProfileId = draft.requestHeaderProfileId.trim();
|
|
348
|
+
} else {
|
|
349
|
+
delete next.requestHeaderProfileId;
|
|
350
|
+
}
|
|
351
|
+
if (draft.clientHeaderProfile === "custom" && hasStringRecordEntries(draft.customClientHeaders)) {
|
|
352
|
+
next.customClientHeaders = cloneStringRecord(draft.customClientHeaders);
|
|
353
|
+
} else {
|
|
354
|
+
delete next.customClientHeaders;
|
|
355
|
+
}
|
|
356
|
+
return next;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function buildModelFromDraft(
|
|
360
|
+
existing: StoredModel | undefined,
|
|
361
|
+
draft: ModelDraft,
|
|
362
|
+
providerCompat: CompatSettings | undefined = undefined,
|
|
363
|
+
): StoredModel {
|
|
364
|
+
const modelId = draft.modelId.trim();
|
|
365
|
+
const reasoning = draft.reasoningMode === "enabled";
|
|
366
|
+
const compat: CompatSettings = cloneJson(existing?.compat) ?? {};
|
|
367
|
+
const effectiveApi = isApiKind(existing?.api) ? existing.api : draft.api;
|
|
368
|
+
const storedThinkingLevelMap = existing?.id === modelId ? cloneJson(existing.thinkingLevelMap) : undefined;
|
|
369
|
+
const thinkingLevelMap = normalizeThinkingLevelMap(effectiveApi, reasoning, storedThinkingLevelMap);
|
|
370
|
+
|
|
371
|
+
const next: StoredModel = {
|
|
372
|
+
id: modelId,
|
|
373
|
+
reasoning,
|
|
374
|
+
input: [...draft.inputKinds],
|
|
375
|
+
contextWindow: draft.contextWindow,
|
|
376
|
+
maxTokens: draft.maxTokens,
|
|
377
|
+
cost: cloneJson(existing?.cost) ?? { ...ZERO_COST },
|
|
378
|
+
};
|
|
379
|
+
if (existing?.api) next.api = existing.api;
|
|
380
|
+
if (existing?.baseUrl) next.baseUrl = existing.baseUrl;
|
|
381
|
+
if (hasStringRecordEntries(existing?.headers)) next.headers = cloneStringRecord(existing?.headers);
|
|
382
|
+
|
|
383
|
+
const name = draft.modelName.trim();
|
|
384
|
+
if (name) next.name = name;
|
|
385
|
+
|
|
386
|
+
if (thinkingLevelMap) next.thinkingLevelMap = thinkingLevelMap;
|
|
387
|
+
|
|
388
|
+
if (effectiveApi === "openai-responses" && draft.openAIServiceTier === "priority") {
|
|
389
|
+
next.openAIServiceTier = "priority";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (effectiveApi === "anthropic-messages" && draft.anthropicThinkingProtocol) {
|
|
393
|
+
const existingOverride = existing?.compat?.forceAdaptiveThinking;
|
|
394
|
+
const providerForcesAdaptiveThinking = providerCompat?.forceAdaptiveThinking === true;
|
|
395
|
+
if (draft.anthropicThinkingProtocol === "adaptive") {
|
|
396
|
+
if (existingOverride === true || !providerForcesAdaptiveThinking) compat.forceAdaptiveThinking = true;
|
|
397
|
+
else delete compat.forceAdaptiveThinking;
|
|
398
|
+
} else if (existingOverride === false || providerForcesAdaptiveThinking) {
|
|
399
|
+
compat.forceAdaptiveThinking = false;
|
|
400
|
+
} else {
|
|
401
|
+
delete compat.forceAdaptiveThinking;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (Object.keys(compat).length > 0) next.compat = compat;
|
|
406
|
+
|
|
407
|
+
return next;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ========== 请求头 ==========
|
|
411
|
+
|
|
412
|
+
export function createRequestHeaderProfileDraft(): RequestHeaderProfileDraft {
|
|
413
|
+
return {
|
|
414
|
+
profileId: "custom-headers",
|
|
415
|
+
profileName: "自定义请求头",
|
|
416
|
+
headers: {},
|
|
417
|
+
selectedIndex: 0,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function createRequestHeaderProfileDraftFromStored(
|
|
422
|
+
profileId: string,
|
|
423
|
+
profile: StoredRequestHeaderProfile,
|
|
424
|
+
): RequestHeaderProfileDraft {
|
|
425
|
+
return {
|
|
426
|
+
profileId,
|
|
427
|
+
profileName: profile.name,
|
|
428
|
+
headers: cloneStringRecord(profile.headers),
|
|
429
|
+
selectedIndex: 0,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function formatRequestHeaderProfileRow(profileId: string, profile: StoredRequestHeaderProfile): string {
|
|
434
|
+
const name = profile.name === profileId ? profileId : `${profile.name} (${profileId})`;
|
|
435
|
+
return `${name} · ${Object.keys(profile.headers).length}项`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function countModelsUsingRequestHeaderProfile(document: StateDocument, profileId: string): number {
|
|
439
|
+
let count = 0;
|
|
440
|
+
for (const provider of Object.values(document.providers)) {
|
|
441
|
+
if (provider.clientHeaderProfile === "custom" && provider.requestHeaderProfileId === profileId) {
|
|
442
|
+
count += provider.models.length;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return count;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function validateRequestHeaderProfileDraft(
|
|
449
|
+
draft: RequestHeaderProfileDraft,
|
|
450
|
+
document: StateDocument,
|
|
451
|
+
oldProfileId?: string,
|
|
452
|
+
): string[] {
|
|
453
|
+
const errors: string[] = [];
|
|
454
|
+
const profileId = draft.profileId.trim();
|
|
455
|
+
if (!profileId) errors.push("请求头 ID 不能为空");
|
|
456
|
+
else if (!ID_PATTERN.test(profileId)) errors.push("请求头 ID 只能包含字母、数字、点、下划线和连字符");
|
|
457
|
+
else if (RESERVED_REQUEST_HEADER_PROFILE_IDS.has(profileId)) errors.push(`请求头 ID 是插件内置保留名:${profileId}`);
|
|
458
|
+
if (profileId && oldProfileId !== profileId && document.requestHeaderProfiles[profileId]) {
|
|
459
|
+
errors.push(`请求头 ID 已存在:${profileId}`);
|
|
460
|
+
}
|
|
461
|
+
if (!draft.profileName.trim()) errors.push("请求头名称不能为空");
|
|
462
|
+
if (!hasStringRecordEntries(draft.headers)) errors.push("请求头至少需要 1 项");
|
|
463
|
+
const sensitiveHeaders = Object.keys(draft.headers).filter((name) => SENSITIVE_REQUEST_HEADER_NAMES.has(name.toLowerCase()));
|
|
464
|
+
if (sensitiveHeaders.length > 0) {
|
|
465
|
+
errors.push(`请求头包含会明文落盘的敏感字段:${sensitiveHeaders.join("、")}(请改用 provider API key / 环境变量 / !command)`);
|
|
466
|
+
}
|
|
467
|
+
return errors;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function upsertRequestHeaderProfileInDocument(
|
|
471
|
+
document: StateDocument,
|
|
472
|
+
oldProfileId: string | undefined,
|
|
473
|
+
draft: RequestHeaderProfileDraft,
|
|
474
|
+
): StateDocument {
|
|
475
|
+
const next: StateDocument = cloneJson(document);
|
|
476
|
+
const profileId = draft.profileId.trim();
|
|
477
|
+
if (oldProfileId && oldProfileId !== profileId) {
|
|
478
|
+
delete next.requestHeaderProfiles[oldProfileId];
|
|
479
|
+
for (const provider of Object.values(next.providers)) {
|
|
480
|
+
if (provider.requestHeaderProfileId === oldProfileId) provider.requestHeaderProfileId = profileId;
|
|
481
|
+
for (const model of provider.models ?? []) {
|
|
482
|
+
if (model.requestHeaderProfileId === oldProfileId) model.requestHeaderProfileId = profileId;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
next.requestHeaderProfiles[profileId] = {
|
|
487
|
+
name: draft.profileName.trim(),
|
|
488
|
+
headers: cloneStringRecord(draft.headers),
|
|
489
|
+
};
|
|
490
|
+
return next;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function deleteRequestHeaderProfileFromDocument(document: StateDocument, profileId: string): StateDocument {
|
|
494
|
+
const next: StateDocument = cloneJson(document);
|
|
495
|
+
delete next.requestHeaderProfiles[profileId];
|
|
496
|
+
for (const provider of Object.values(next.providers)) {
|
|
497
|
+
if (provider.requestHeaderProfileId === profileId) {
|
|
498
|
+
provider.clientHeaderProfile = "recommended";
|
|
499
|
+
delete provider.requestHeaderProfileId;
|
|
500
|
+
}
|
|
501
|
+
for (const model of provider.models ?? []) {
|
|
502
|
+
if (model.requestHeaderProfileId !== profileId) continue;
|
|
503
|
+
model.clientHeaderProfile = "recommended";
|
|
504
|
+
delete model.requestHeaderProfileId;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return next;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ========== upsert / delete on document ==========
|
|
511
|
+
|
|
512
|
+
function upsertModel(models: StoredModel[], next: StoredModel, replacedId: string | undefined): StoredModel[] {
|
|
513
|
+
const retained = models.filter((m) => m.id !== next.id && m.id !== replacedId);
|
|
514
|
+
return [...retained, next];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function upsertProviderInDocument(
|
|
518
|
+
document: StateDocument,
|
|
519
|
+
oldProviderId: string | undefined,
|
|
520
|
+
draft: ProviderDraft,
|
|
521
|
+
): StateDocument {
|
|
522
|
+
const next: StateDocument = cloneJson(document);
|
|
523
|
+
const sourceProvider = oldProviderId
|
|
524
|
+
? next.providers[oldProviderId]
|
|
525
|
+
: next.providers[draft.providerId];
|
|
526
|
+
const nextProvider = buildProviderFromDraft(sourceProvider, draft);
|
|
527
|
+
if (oldProviderId && oldProviderId !== draft.providerId) {
|
|
528
|
+
delete next.providers[oldProviderId];
|
|
529
|
+
}
|
|
530
|
+
next.providers[draft.providerId] = nextProvider;
|
|
531
|
+
return next;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function upsertModelInDocument(
|
|
535
|
+
document: StateDocument,
|
|
536
|
+
draft: ModelDraft,
|
|
537
|
+
options: { replacedModelId?: string } = {},
|
|
538
|
+
): StateDocument {
|
|
539
|
+
const next: StateDocument = cloneJson(document);
|
|
540
|
+
const provider = next.providers[draft.providerId];
|
|
541
|
+
if (!provider) throw new Error(`upsertModel:接入不存在:${draft.providerId}`);
|
|
542
|
+
const existing = (provider.models ?? []).find(
|
|
543
|
+
(m) => m.id === options.replacedModelId || m.id === draft.modelId.trim(),
|
|
544
|
+
);
|
|
545
|
+
const newModel = buildModelFromDraft(existing, draft, provider.compat);
|
|
546
|
+
provider.models = upsertModel(provider.models ?? [], newModel, options.replacedModelId);
|
|
547
|
+
return next;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function deleteModelFromDocument(
|
|
551
|
+
document: StateDocument,
|
|
552
|
+
providerId: string,
|
|
553
|
+
modelId: string,
|
|
554
|
+
): StateDocument {
|
|
555
|
+
const next: StateDocument = cloneJson(document);
|
|
556
|
+
const provider = next.providers[providerId];
|
|
557
|
+
if (!provider) return next;
|
|
558
|
+
provider.models = (provider.models ?? []).filter((m) => m.id !== modelId);
|
|
559
|
+
return next;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function deleteProviderFromDocument(
|
|
563
|
+
document: StateDocument,
|
|
564
|
+
providerId: string,
|
|
565
|
+
): StateDocument {
|
|
566
|
+
const next: StateDocument = cloneJson(document);
|
|
567
|
+
delete next.providers[providerId];
|
|
568
|
+
return next;
|
|
569
|
+
}
|