@wax0629/pi-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/src/store.mjs ADDED
@@ -0,0 +1,615 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { clone, createDefaultState } from "./defaults.mjs";
6
+ import { previewPiProviderImport, summarizePiProvider, literalApiKeyFromPiProvider } from "./pi-import.mjs";
7
+ import { deleteSecret, getSecret, hasSecret, setSecret } from "./secrets.mjs";
8
+ import {
9
+ THINKING_MAP_SOURCES,
10
+ assertSupportedThinkingLevel,
11
+ getSupportedThinkingLevels,
12
+ normalizeThinkingLevelMap,
13
+ normalizeThinkingMapSource,
14
+ validateThinkingLevelMap
15
+ } from "./thinking.mjs";
16
+
17
+ function safeId(value) {
18
+ return String(value || "")
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9_-]+/g, "-")
22
+ .replace(/^-+|-+$/g, "")
23
+ .slice(0, 48);
24
+ }
25
+
26
+ const customProviderKinds = new Set(["openai-api"]);
27
+ const BUILTIN_PROVIDER_IDS = new Set(["qiniu", "antigravity", "openai-codex"]);
28
+ const DEFAULT_CONTEXT_WINDOW = 128000;
29
+ const MAX_CONTEXT_WINDOW = 100000000;
30
+
31
+ function isBuiltinProviderId(providerId) {
32
+ return BUILTIN_PROVIDER_IDS.has(String(providerId || "").trim());
33
+ }
34
+
35
+ function isEditableCustomProvider(provider) {
36
+ return Boolean(provider) && provider.kind === "openai-api";
37
+ }
38
+
39
+ function canConfigureProviderCredential(provider) {
40
+ return Boolean(provider) && provider.kind !== "native-subscription";
41
+ }
42
+
43
+ function assertHttpUrl(value) {
44
+ let normalizedUrl;
45
+ try {
46
+ normalizedUrl = new URL(String(value || "").trim());
47
+ } catch {
48
+ throw new Error("Base URL 必须是有效的 http(s) 地址");
49
+ }
50
+ if (!/^https?:$/i.test(normalizedUrl.protocol)) throw new Error("Base URL 必须是有效的 http(s) 地址");
51
+ return normalizedUrl.toString().replace(/\/$/, "");
52
+ }
53
+
54
+ function parseModelInputs(models) {
55
+ if (Array.isArray(models)) return models;
56
+ return String(models || "").split(",");
57
+ }
58
+
59
+ function mergeProviderModels(models, existingModels = []) {
60
+ const existingById = new Map((existingModels || []).map((model) => [model.id, model]));
61
+ const nextModels = [];
62
+ const seen = new Set();
63
+ for (const item of parseModelInputs(models)) {
64
+ const incoming = typeof item === "string" ? { id: item.trim() } : item;
65
+ const id = String(incoming?.id || "").trim();
66
+ if (!id || seen.has(id)) continue;
67
+ seen.add(id);
68
+ const previous = existingById.get(id);
69
+ const merged = normalizeModel(previous ? { ...previous, ...incoming, id } : incoming);
70
+ if (merged) nextModels.push(merged);
71
+ }
72
+ return nextModels;
73
+ }
74
+
75
+ function normalizeContextWindow(value, fallback = DEFAULT_CONTEXT_WINDOW) {
76
+ const candidate = Number(value);
77
+ return Number.isSafeInteger(candidate) && candidate > 0 && candidate <= MAX_CONTEXT_WINDOW
78
+ ? candidate
79
+ : fallback;
80
+ }
81
+
82
+ function assertContextWindow(value) {
83
+ if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") {
84
+ throw new Error("Context 长度必须是正整数");
85
+ }
86
+ const candidate = Number(value);
87
+ if (!Number.isSafeInteger(candidate) || candidate <= 0 || candidate > MAX_CONTEXT_WINDOW) {
88
+ throw new Error(`Context 长度必须是 1-${MAX_CONTEXT_WINDOW} 之间的正整数`);
89
+ }
90
+ return candidate;
91
+ }
92
+
93
+ function normalizeModel(item) {
94
+ const source = typeof item === "string" ? { id: item, name: item } : item;
95
+ if (!source || typeof source !== "object") return null;
96
+ const id = String(source.id || "").trim();
97
+ if (!id) return null;
98
+ const reasoning = Boolean(source.reasoning);
99
+ const legacyThinkingLevels = Array.isArray(source.thinkingLevels)
100
+ ? source.thinkingLevels.map((level) => String(level))
101
+ : undefined;
102
+ const thinkingLevelMap = normalizeThinkingLevelMap(source.thinkingLevelMap, {
103
+ reasoning,
104
+ thinkingLevels: legacyThinkingLevels
105
+ });
106
+ return {
107
+ id,
108
+ name: String(source.name || id).trim() || id,
109
+ reasoning,
110
+ thinkingLevels: getSupportedThinkingLevels({ reasoning, thinkingLevelMap }),
111
+ thinkingLevelMap,
112
+ thinkingMapSource: normalizeThinkingMapSource(source.thinkingMapSource),
113
+ thinkingMapVerified: Boolean(source.thinkingMapVerified),
114
+ input: Array.isArray(source.input) && source.input.length ? source.input.map((item) => String(item)) : ["text"],
115
+ contextWindow: normalizeContextWindow(source.contextWindow),
116
+ maxTokens: Number(source.maxTokens) || 32000,
117
+ cost: source.cost || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
118
+ };
119
+ }
120
+
121
+ function normalizeCycleModelRefs(value) {
122
+ if (!Array.isArray(value)) return [];
123
+ const refs = [];
124
+ const seen = new Set();
125
+ for (const item of value) {
126
+ const ref = String(item || "").trim();
127
+ if (!ref || seen.has(ref)) continue;
128
+ seen.add(ref);
129
+ refs.push(ref);
130
+ }
131
+ return refs;
132
+ }
133
+
134
+ function captureConfigurationSnapshot(state) {
135
+ return clone({
136
+ targetProject: state.targetProject,
137
+ active: state.active,
138
+ cycle: state.cycle,
139
+ gateway: state.gateway,
140
+ providers: state.providers
141
+ });
142
+ }
143
+
144
+ function ensureDir(dir) {
145
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
146
+ }
147
+
148
+ function writeJsonAtomic(filePath, value, mode = 0o600) {
149
+ ensureDir(path.dirname(filePath));
150
+ const temp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
151
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode });
152
+ try {
153
+ fs.chmodSync(temp, mode);
154
+ } catch {
155
+ // Best effort on filesystems without POSIX permissions.
156
+ }
157
+ fs.renameSync(temp, filePath);
158
+ }
159
+
160
+ function mergeProvider(defaultProvider, savedProvider) {
161
+ return {
162
+ ...defaultProvider,
163
+ ...savedProvider,
164
+ models: Array.isArray(savedProvider?.models) && savedProvider.models.length
165
+ ? savedProvider.models.map(normalizeModel).filter(Boolean)
166
+ : defaultProvider.models
167
+ };
168
+ }
169
+
170
+ function normalizeState(defaultState, savedState) {
171
+ if (!savedState || typeof savedState !== "object") return defaultState;
172
+ const providersById = new Map((savedState.providers || []).map((provider) => [provider.id, provider]));
173
+ const providers = defaultState.providers.map((provider) => mergeProvider(provider, providersById.get(provider.id)));
174
+ for (const provider of savedState.providers || []) {
175
+ if (!providers.some((item) => item.id === provider.id)) {
176
+ providers.push({
177
+ ...provider,
178
+ models: Array.isArray(provider.models) ? provider.models.map(normalizeModel).filter(Boolean) : []
179
+ });
180
+ }
181
+ }
182
+ const merged = {
183
+ ...defaultState,
184
+ ...savedState,
185
+ active: { ...defaultState.active, ...(savedState.active || {}) },
186
+ cycle: Object.prototype.hasOwnProperty.call(savedState, "cycle")
187
+ ? {
188
+ modelRefs: Array.isArray(savedState.cycle?.modelRefs)
189
+ ? normalizeCycleModelRefs(savedState.cycle.modelRefs)
190
+ : [...defaultState.cycle.modelRefs]
191
+ }
192
+ : { ...defaultState.cycle },
193
+ gateway: { ...defaultState.gateway, ...(savedState.gateway || {}) },
194
+ providers,
195
+ runtime: { ...defaultState.runtime, ...(savedState.runtime || {}) }
196
+ };
197
+ if (!merged.gateway.clientKey) merged.gateway.clientKey = crypto.randomBytes(24).toString("base64url");
198
+ if (!Array.isArray(merged.runtime.events)) merged.runtime.events = [];
199
+ if (merged.runtime.appliedSnapshot === undefined) merged.runtime.appliedSnapshot = null;
200
+ if (!Number.isInteger(merged.runtime.configRevision) || merged.runtime.configRevision < 1) merged.runtime.configRevision = 1;
201
+ if (!Number.isInteger(merged.runtime.appliedRevision) || merged.runtime.appliedRevision < 0) merged.runtime.appliedRevision = 0;
202
+ if (merged.runtime.lastLiveImportAt === undefined) merged.runtime.lastLiveImportAt = null;
203
+ if (!merged.runtime.lastLiveBackupDir) merged.runtime.lastLiveBackupDir = "";
204
+ if (merged.runtime.lastLiveVerify === undefined) merged.runtime.lastLiveVerify = null;
205
+ return merged;
206
+ }
207
+
208
+ function defaultDataDir() {
209
+ return process.env.PI_MANAGER_HOME || path.join(os.homedir(), ".pi-manager");
210
+ }
211
+
212
+ export function createStore({ projectRoot, dataDir = defaultDataDir() }) {
213
+ ensureDir(dataDir);
214
+ const statePath = path.join(dataDir, "state.json");
215
+ const defaultState = createDefaultState({ projectRoot });
216
+ let state;
217
+ try {
218
+ state = normalizeState(defaultState, JSON.parse(fs.readFileSync(statePath, "utf8")));
219
+ } catch {
220
+ state = normalizeState(defaultState, null);
221
+ }
222
+ if (!state.targetProject || !fs.existsSync(state.targetProject)) state.targetProject = projectRoot;
223
+ writeJsonAtomic(statePath, state);
224
+
225
+ const persist = () => writeJsonAtomic(statePath, state);
226
+
227
+ const store = {
228
+ dataDir,
229
+ statePath,
230
+ get() {
231
+ return state;
232
+ },
233
+ snapshot() {
234
+ return clone(state);
235
+ },
236
+ snapshotConfiguration() {
237
+ return captureConfigurationSnapshot(state);
238
+ },
239
+ restoreConfiguration(snapshot) {
240
+ const normalized = normalizeState(defaultState, snapshot);
241
+ state.targetProject = normalized.targetProject;
242
+ state.active = normalized.active;
243
+ state.cycle = normalized.cycle;
244
+ state.gateway = normalized.gateway;
245
+ state.providers = normalized.providers;
246
+ persist();
247
+ return state;
248
+ },
249
+ save() {
250
+ persist();
251
+ },
252
+ update(mutator) {
253
+ mutator(state);
254
+ persist();
255
+ return state;
256
+ },
257
+ provider(providerId) {
258
+ return state.providers.find((provider) => provider.id === providerId);
259
+ },
260
+ credentialConfigured(provider) {
261
+ return provider.kind === "native-subscription" || hasSecret({ dataDir, provider });
262
+ },
263
+ credential(provider) {
264
+ return getSecret({ dataDir, provider });
265
+ },
266
+ setCredential(providerId, value) {
267
+ const provider = store.provider(providerId);
268
+ if (!provider) throw new Error("渠道不存在");
269
+ if (!canConfigureProviderCredential(provider)) throw new Error("原生订阅渠道请使用 Pi 登录,不能在此配置 API key");
270
+ const result = setSecret({ dataDir, providerId, value });
271
+ store.touchConfiguration();
272
+ store.recordEvent("credential", `已更新 ${provider.name} 凭据`, result.storage);
273
+ return result;
274
+ },
275
+ deleteCredential(providerId) {
276
+ const provider = store.provider(providerId);
277
+ if (!provider) throw new Error("渠道不存在");
278
+ if (!canConfigureProviderCredential(provider)) throw new Error("原生订阅渠道请使用 Pi 登录,不能在此配置 API key");
279
+ deleteSecret({ dataDir, providerId });
280
+ store.touchConfiguration();
281
+ store.recordEvent("credential", `已移除 ${provider.name} 凭据`, "");
282
+ },
283
+ touchConfiguration() {
284
+ state.runtime.configRevision += 1;
285
+ persist();
286
+ return state.runtime.configRevision;
287
+ },
288
+ recordEvent(type, message, detail = "") {
289
+ state.runtime.events = [
290
+ { id: crypto.randomUUID(), at: new Date().toISOString(), type, message, detail },
291
+ ...(state.runtime.events || [])
292
+ ].slice(0, 60);
293
+ persist();
294
+ },
295
+ setActive({ providerId, modelId, thinking }) {
296
+ const provider = store.provider(providerId);
297
+ if (!provider) throw new Error("渠道不存在");
298
+ const model = provider.models.find((item) => item.id === modelId);
299
+ if (!model) throw new Error("模型不存在");
300
+ const levels = getSupportedThinkingLevels(model);
301
+ const nextThinking = thinking === undefined || thinking === null ? levels[levels.length - 1] : String(thinking);
302
+ assertSupportedThinkingLevel(model, nextThinking);
303
+ state.active = { providerId, modelId, thinking: nextThinking };
304
+ store.touchConfiguration();
305
+ store.recordEvent("route", `已切换到 ${provider.name} / ${model.name}`, nextThinking);
306
+ return state.active;
307
+ },
308
+ updateThinkingMap({ providerId, modelId, thinkingLevelMap, source = "user", verified = false }) {
309
+ const provider = store.provider(providerId);
310
+ if (!provider) throw new Error("渠道不存在");
311
+ const model = provider.models.find((item) => item.id === modelId);
312
+ if (!model) throw new Error("模型不存在");
313
+ if (!THINKING_MAP_SOURCES.includes(source)) throw new Error(`未知 Thinking 映射来源: ${source}`);
314
+ const normalizedMap = validateThinkingLevelMap(thinkingLevelMap, { reasoning: model.reasoning });
315
+ const nextModel = { ...model, thinkingLevelMap: normalizedMap };
316
+ const nextLevels = getSupportedThinkingLevels(nextModel);
317
+ if (state.active.providerId === providerId && state.active.modelId === modelId) {
318
+ assertSupportedThinkingLevel(nextModel, state.active.thinking);
319
+ }
320
+ model.thinkingLevelMap = normalizedMap;
321
+ model.thinkingLevels = nextLevels;
322
+ model.thinkingMapSource = source;
323
+ model.thinkingMapVerified = Boolean(verified);
324
+ store.touchConfiguration();
325
+ store.recordEvent("model", `已更新 ${provider.name} / ${model.name} Thinking 映射`, source);
326
+ return model;
327
+ },
328
+ addProviderModel({ providerId, model }) {
329
+ const provider = store.provider(providerId);
330
+ if (!provider) throw new Error("渠道不存在");
331
+ if (!isEditableCustomProvider(provider)) throw new Error("只有 OpenAI 兼容 API 渠道可以增删模型");
332
+ const incoming = typeof model === "string" ? { id: model } : model;
333
+ const nextModel = normalizeModel(incoming);
334
+ if (!nextModel) throw new Error("模型 ID 不能为空");
335
+ if (provider.models.some((item) => item.id === nextModel.id)) throw new Error("模型 ID 已存在");
336
+ provider.models.push(nextModel);
337
+ store.touchConfiguration();
338
+ store.recordEvent("model", `已添加 ${provider.name} / ${nextModel.name}`, nextModel.id);
339
+ return nextModel;
340
+ },
341
+ removeProviderModel({ providerId, modelId }) {
342
+ const provider = store.provider(providerId);
343
+ if (!provider) throw new Error("渠道不存在");
344
+ if (!isEditableCustomProvider(provider)) throw new Error("只有 OpenAI 兼容 API 渠道可以增删模型");
345
+ const normalizedModelId = String(modelId || "").trim();
346
+ const index = provider.models.findIndex((item) => item.id === normalizedModelId);
347
+ if (index === -1) throw new Error("模型不存在");
348
+ if (provider.models.length === 1) throw new Error("至少保留一个模型");
349
+ if (state.active.providerId === providerId && state.active.modelId === normalizedModelId) {
350
+ throw new Error("不能删除当前默认模型,请先切换默认模型");
351
+ }
352
+ const [removed] = provider.models.splice(index, 1);
353
+ state.cycle.modelRefs = normalizeCycleModelRefs(
354
+ state.cycle.modelRefs.filter((ref) => ref !== `${providerId}/${normalizedModelId}`)
355
+ );
356
+ store.touchConfiguration();
357
+ store.recordEvent("model", `已删除 ${provider.name} / ${removed.name}`, normalizedModelId);
358
+ return removed;
359
+ },
360
+ updateModelContextWindow({ providerId, modelId, contextWindow }) {
361
+ const provider = store.provider(providerId);
362
+ if (!provider) throw new Error("渠道不存在");
363
+ const model = provider.models.find((item) => item.id === modelId);
364
+ if (!model) throw new Error("模型不存在");
365
+ const nextContextWindow = assertContextWindow(contextWindow);
366
+ model.contextWindow = nextContextWindow;
367
+ store.touchConfiguration();
368
+ store.recordEvent("model", `已更新 ${provider.name} / ${model.name} Context 长度`, String(nextContextWindow));
369
+ return model;
370
+ },
371
+ updateCycleList(modelRefs) {
372
+ const nextRefs = normalizeCycleModelRefs(modelRefs);
373
+ state.cycle.modelRefs = nextRefs;
374
+ store.touchConfiguration();
375
+ store.recordEvent("model", "已更新循环列表", nextRefs.join(" / "));
376
+ return nextRefs;
377
+ },
378
+ restoreAppliedConfiguration() {
379
+ const appliedSnapshot = state.runtime.appliedSnapshot;
380
+ if (!appliedSnapshot || typeof appliedSnapshot !== "object") {
381
+ throw new Error("没有可回滚的已应用配置");
382
+ }
383
+ store.restoreConfiguration(appliedSnapshot);
384
+ state.runtime.configRevision += 1;
385
+ state.runtime.lastError = null;
386
+ persist();
387
+ return state;
388
+ },
389
+ addProvider({ id: requestedId, name, kind = "openai-api", baseUrl, models = [], credentialEnv = "", apiKey = "" }) {
390
+ const normalizedName = String(name || "").trim();
391
+ if (!normalizedName) throw new Error("渠道名称不能为空");
392
+ if (!customProviderKinds.has(kind)) throw new Error("当前仅支持 OpenAI 兼容 API");
393
+ const normalizedUrl = assertHttpUrl(baseUrl);
394
+ const baseId = safeId(requestedId || normalizedName) || "provider";
395
+ if (isBuiltinProviderId(baseId) && requestedId) throw new Error("不能占用内置渠道 ID");
396
+ let id = baseId;
397
+ let counter = 2;
398
+ while (store.provider(id)) id = `${baseId}-${counter++}`;
399
+ const normalizedModels = mergeProviderModels(models);
400
+ if (!normalizedModels.length) throw new Error("至少填写一个模型 ID");
401
+ const provider = {
402
+ id,
403
+ name: normalizedName,
404
+ kind,
405
+ baseUrl: normalizedUrl,
406
+ credentialEnv: String(credentialEnv || "").trim(),
407
+ description: "自定义 OpenAI 兼容 API",
408
+ models: normalizedModels
409
+ };
410
+ state.providers.push(provider);
411
+ try {
412
+ if (String(apiKey || "").trim()) setSecret({ dataDir, providerId: id, value: apiKey });
413
+ } catch (error) {
414
+ state.providers.pop();
415
+ throw error;
416
+ }
417
+ store.touchConfiguration();
418
+ store.recordEvent("provider", `已添加渠道 ${normalizedName}`, id);
419
+ return provider;
420
+ },
421
+ updateProvider(providerId, {
422
+ id: requestedId,
423
+ name,
424
+ baseUrl,
425
+ models,
426
+ apiKey
427
+ } = {}) {
428
+ const provider = store.provider(providerId);
429
+ if (!provider) throw new Error("渠道不存在");
430
+ if (!isEditableCustomProvider(provider)) throw new Error("只有 OpenAI 兼容 API 渠道可以编辑");
431
+
432
+ const nextName = name === undefined ? provider.name : String(name || "").trim();
433
+ if (!nextName) throw new Error("渠道名称不能为空");
434
+ const nextBaseUrl = baseUrl === undefined ? provider.baseUrl : assertHttpUrl(baseUrl);
435
+ const nextModels = models === undefined
436
+ ? provider.models
437
+ : mergeProviderModels(models, provider.models);
438
+ if (!nextModels.length) throw new Error("至少填写一个模型 ID");
439
+
440
+ const nextId = requestedId === undefined || requestedId === null || String(requestedId).trim() === ""
441
+ ? provider.id
442
+ : (safeId(requestedId) || provider.id);
443
+ if (nextId !== provider.id) {
444
+ if (isBuiltinProviderId(nextId)) throw new Error("不能占用内置渠道 ID");
445
+ if (store.provider(nextId)) throw new Error("Provider ID 已存在");
446
+ }
447
+
448
+ const nextModelIds = new Set(nextModels.map((model) => model.id));
449
+ if (state.active.providerId === provider.id && !nextModelIds.has(state.active.modelId)) {
450
+ throw new Error("不能删除当前正在使用的模型,请先切换默认模型");
451
+ }
452
+
453
+ const previousId = provider.id;
454
+ const previousSecret = store.credential(provider);
455
+ provider.name = nextName;
456
+ provider.baseUrl = nextBaseUrl;
457
+ provider.models = nextModels;
458
+ if (nextId !== previousId) {
459
+ provider.id = nextId;
460
+ if (state.active.providerId === previousId) state.active.providerId = nextId;
461
+ state.cycle.modelRefs = state.cycle.modelRefs.map((ref) => {
462
+ const slash = String(ref).indexOf("/");
463
+ if (slash <= 0) return ref;
464
+ const refProviderId = ref.slice(0, slash);
465
+ return refProviderId === previousId ? `${nextId}${ref.slice(slash)}` : ref;
466
+ });
467
+ if (previousSecret) {
468
+ setSecret({ dataDir, providerId: nextId, value: previousSecret });
469
+ deleteSecret({ dataDir, providerId: previousId });
470
+ }
471
+ }
472
+ state.cycle.modelRefs = normalizeCycleModelRefs(state.cycle.modelRefs.filter((ref) => {
473
+ const slash = String(ref).indexOf("/");
474
+ if (slash <= 0) return true;
475
+ const refProviderId = ref.slice(0, slash);
476
+ const refModelId = ref.slice(slash + 1);
477
+ if (refProviderId !== provider.id) return true;
478
+ return nextModelIds.has(refModelId);
479
+ }));
480
+
481
+ if (apiKey !== undefined) {
482
+ const normalizedKey = String(apiKey || "").trim();
483
+ if (normalizedKey) setSecret({ dataDir, providerId: provider.id, value: normalizedKey });
484
+ }
485
+
486
+ store.touchConfiguration();
487
+ store.recordEvent("provider", `已更新渠道 ${nextName}`, provider.id);
488
+ return provider;
489
+ },
490
+ removeNativeProvider(providerId) {
491
+ const existing = store.provider(providerId);
492
+ if (!existing || existing.kind !== "native-subscription") return false;
493
+ state.providers = state.providers.filter((provider) => provider.id !== providerId);
494
+ state.cycle.modelRefs = normalizeCycleModelRefs(
495
+ state.cycle.modelRefs.filter((ref) => !String(ref).startsWith(`${providerId}/`))
496
+ );
497
+ if (state.active.providerId === providerId) {
498
+ state.active = { providerId: "", modelId: "", thinking: "off" };
499
+ }
500
+ store.touchConfiguration();
501
+ store.recordEvent("provider", `已移除 Pi 原生渠道 ${existing.name}`, providerId);
502
+ return true;
503
+ },
504
+ removeProvider(providerId) {
505
+ const existing = store.provider(providerId);
506
+ if (existing?.kind === "native-subscription") throw new Error("原生订阅渠道不能从这里删除,请先退出登录");
507
+ if (state.active.providerId === providerId) throw new Error("当前渠道正在使用,请先切换");
508
+ const index = state.providers.findIndex((provider) => provider.id === providerId);
509
+ if (index === -1) throw new Error("渠道不存在");
510
+ const [removed] = state.providers.splice(index, 1);
511
+ deleteSecret({ dataDir, providerId });
512
+ state.cycle.modelRefs = normalizeCycleModelRefs(
513
+ state.cycle.modelRefs.filter((ref) => !String(ref).startsWith(`${providerId}/`))
514
+ );
515
+ store.touchConfiguration();
516
+ store.recordEvent("provider", `已删除渠道 ${removed.name}`, providerId);
517
+ },
518
+ previewPiImport(modelsConfig) {
519
+ return previewPiProviderImport({
520
+ modelsConfig,
521
+ existingProviders: state.providers
522
+ });
523
+ },
524
+ upsertNativeProvider(summary) {
525
+ const existing = store.provider(summary.id);
526
+ const models = mergeProviderModels(summary.models || [], existing?.models || []);
527
+ if (existing) {
528
+ if (existing.kind === "native-subscription") {
529
+ existing.name = summary.name || existing.name;
530
+ existing.piProvider = summary.piProvider || existing.piProvider || existing.id;
531
+ if (models.length) existing.models = models;
532
+ }
533
+ return existing;
534
+ }
535
+ const provider = {
536
+ id: summary.id,
537
+ name: summary.name || summary.id,
538
+ kind: "native-subscription",
539
+ piProvider: summary.piProvider || summary.id,
540
+ description: "Pi 原生渠道",
541
+ models
542
+ };
543
+ state.providers.push(provider);
544
+ store.touchConfiguration();
545
+ store.recordEvent("provider", `已接入 Pi 原生渠道 ${provider.name}`, provider.id);
546
+ return provider;
547
+ },
548
+ importPiProviders({ modelsConfig, overwrite = false, providerIds } = {}) {
549
+ const selectedIds = Array.isArray(providerIds)
550
+ ? [...new Set(providerIds.map((id) => String(id || "").trim()).filter(Boolean))]
551
+ : [];
552
+ if (!selectedIds.length) throw new Error("请先勾选要导入的渠道");
553
+ const preview = store.previewPiImport(modelsConfig);
554
+ const selected = preview.candidates.filter((item) => selectedIds.includes(item.id));
555
+ if (!selected.length) throw new Error("勾选的渠道不可导入");
556
+ const selectedConflicts = selected.filter((item) => item.conflict);
557
+ if (!overwrite && selectedConflicts.length > 0) {
558
+ const ids = selectedConflicts.map((item) => item.id).join(", ");
559
+ throw new Error(`以下渠道已存在,需确认后才能覆盖: ${ids}`);
560
+ }
561
+ const imported = [];
562
+ const skipped = [...preview.skipped];
563
+ for (const id of selectedIds) {
564
+ if (!preview.candidates.some((item) => item.id === id)) {
565
+ skipped.push({ id, reason: "not-selected-or-unavailable" });
566
+ }
567
+ }
568
+ for (const candidate of selected) {
569
+ const source = modelsConfig?.providers?.[candidate.id];
570
+ if (!source) continue;
571
+ const summary = summarizePiProvider(candidate.id, source);
572
+ const models = mergeProviderModels(summary.models, store.provider(candidate.id)?.models || []);
573
+ if (!models.length) {
574
+ skipped.push({ id: candidate.id, reason: "missing-models-or-baseurl" });
575
+ continue;
576
+ }
577
+ const existing = store.provider(candidate.id);
578
+ if (existing) {
579
+ if (existing.kind === "native-subscription") {
580
+ skipped.push({ id: candidate.id, reason: "native-subscription" });
581
+ continue;
582
+ }
583
+ existing.name = summary.name;
584
+ existing.kind = existing.id === "antigravity" ? "local-bridge" : "openai-api";
585
+ existing.baseUrl = summary.baseUrl;
586
+ existing.credentialEnv = summary.credentialEnv || existing.credentialEnv || "";
587
+ existing.description = existing.description || "从本机 Pi models.json 导入";
588
+ existing.models = models;
589
+ } else {
590
+ state.providers.push({
591
+ id: summary.id,
592
+ name: summary.name,
593
+ kind: summary.id === "antigravity" ? "local-bridge" : "openai-api",
594
+ baseUrl: summary.baseUrl,
595
+ credentialEnv: summary.credentialEnv,
596
+ description: "从本机 Pi models.json 导入",
597
+ models
598
+ });
599
+ }
600
+ const literalKey = literalApiKeyFromPiProvider(source);
601
+ if (literalKey) setSecret({ dataDir, providerId: summary.id, value: literalKey });
602
+ imported.push(summary.id);
603
+ }
604
+ if (imported.length > 0) {
605
+ store.touchConfiguration();
606
+ store.recordEvent("provider", `已从本机 Pi 导入 ${imported.length} 个渠道`, imported.join(", "));
607
+ }
608
+ return { imported, skipped, conflicts: selectedConflicts, modelsPath: preview.modelsPath };
609
+ }
610
+ };
611
+
612
+ return store;
613
+ }
614
+
615
+ export { safeId, writeJsonAtomic, isBuiltinProviderId, isEditableCustomProvider, canConfigureProviderCredential };