opencode-claude-code-ua 0.0.0-stage → 0.3.9

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,660 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { builtinTemplates, validateTemplate } from './ua-transform.mjs';
5
+
6
+ const SCHEMA_VERSION = 1;
7
+ const MAX_STORE_BYTES = 8 * 1024 * 1024;
8
+ const PROTOCOLS = ['anthropic', 'responses', 'chat-completions', 'gemini'];
9
+ const OPERATIONS = ['messages', 'count_tokens', 'responses', 'chat/completions', 'generateContent', 'streamGenerateContent', 'countTokens'];
10
+ const AUTH_MODES = ['api-key', 'bearer', 'custom', 'none'];
11
+ const AUTH_SOURCES = ['stored', 'host'];
12
+ const BUILTIN_IDS = ['builtin.codex', 'builtin.claude-code'];
13
+ const SEED_CUSTOM_IDS = ['custom.claude-vscode-2316'];
14
+ const PROTECTED_SEED_VERSIONS = new Set(['custom.claude-vscode-2316@1']);
15
+ const STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
16
+ const TEMPLATE_ID = /^(?:builtin|custom)\.[A-Za-z0-9][A-Za-z0-9._-]*$/;
17
+ const BAD_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
18
+ const DEFAULT_AUTO_RESOLVED = {
19
+ responses: { id: 'builtin.codex', revision: 1 },
20
+ anthropic: { id: 'builtin.claude-code', revision: 1 },
21
+ };
22
+ const writes = new Map();
23
+
24
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
25
+ const isText = (value) => typeof value === 'string' && value.trim().length > 0;
26
+ const text = (value) => (typeof value === 'string' ? value.trim() : '');
27
+
28
+ function fail(message, status = 400, field) {
29
+ const error = new Error(message);
30
+ error.status = status;
31
+ if (field) error.fields = { [field]: message };
32
+ return error;
33
+ }
34
+
35
+ function onlyKeys(value, allowed, field) {
36
+ if (isObject(value) && Object.keys(value).some((key) => !allowed.includes(key))) {
37
+ throw fail('包含未知字段,请检查字段名称', 400, field);
38
+ }
39
+ }
40
+
41
+ function targetOf(value) {
42
+ try {
43
+ const url = new URL(value);
44
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
45
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function normalizeBaseURL(value) {
52
+ if (typeof value !== 'string' || !value.trim()) throw fail('站点地址不能为空', 400, 'baseURL');
53
+ const raw = value.trim();
54
+ try {
55
+ const url = new URL(raw);
56
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
57
+ if (!/^https?:\/\/[^/?#]+/i.test(raw) || !['http:', 'https:'].includes(url.protocol) ||
58
+ (url.protocol === 'http:' && !loopback) || url.username || url.password || raw.includes('@') ||
59
+ raw.includes('?') || raw.includes('#') || /[\u0000-\u0020\u007f{}\\]/.test(raw) ||
60
+ /\/(messages|responses|chat\/completions)\/?$/i.test(url.pathname)) throw new Error();
61
+ return url.href.replace(/\/+$/, '');
62
+ } catch {
63
+ throw fail('请填写 HTTPS 站点地址(回环地址可用 HTTP),不要包含凭据、查询、片段或具体请求路径', 400, 'baseURL');
64
+ }
65
+ }
66
+
67
+ function safePath(value) {
68
+ return isText(value) && !/[\u0000-\u0020\u007f\\]/.test(value) && !value.includes('://') && !/(^|\/)\.\.(\/|$)/.test(value);
69
+ }
70
+
71
+ /** Store governance (id/kind/revision/archived) around the single validation source in ua-transform. */
72
+ function governTemplate(raw, { id, revision } = {}) {
73
+ if (!isObject(raw)) throw fail('模板必须是对象', 400, 'template');
74
+ const archived = raw.archived ?? false;
75
+ if (typeof archived !== 'boolean') throw fail('模板归档状态无效', 400, 'archived');
76
+ const cleaned = { ...raw };
77
+ delete cleaned.archived;
78
+ if (id !== undefined) cleaned.id = id;
79
+ if (revision !== undefined) cleaned.revision = revision;
80
+ if (!isText(cleaned.id)) throw fail('模板 id 缺失', 400, 'id');
81
+ if (!isText(cleaned.kind)) cleaned.kind = cleaned.id.startsWith('builtin.') ? 'builtin' : 'custom';
82
+ const template = validateTemplate(cleaned);
83
+ if (!TEMPLATE_ID.test(template.id)) throw fail('模板 id 无效', 400, 'id');
84
+ const builtin = template.id.startsWith('builtin.');
85
+ if (builtin !== (template.kind === 'builtin')) throw fail('模板类型与 id 不一致', 400, 'kind');
86
+ if (builtin && !BUILTIN_IDS.includes(template.id)) throw fail('未知内置模板', 400, 'id');
87
+ template.archived = archived;
88
+ return template;
89
+ }
90
+
91
+ /** Governance-only field; false is omitted so transform inputs stay schema clean. */
92
+ function publicTemplate(template) {
93
+ const output = structuredClone(template);
94
+ if (!output.archived) delete output.archived;
95
+ return output;
96
+ }
97
+
98
+ /** Resolution hands transform a schema-clean template; archive is store governance only. */
99
+ function resolvedTemplate(template) {
100
+ const output = structuredClone(template);
101
+ delete output.archived;
102
+ return output;
103
+ }
104
+
105
+ function assertTemplateRef(templates, id, revision) {
106
+ if (!templates.some((item) => item.id === id && item.revision === revision)) {
107
+ throw fail('基线模板版本不存在', 400, 'basedOn');
108
+ }
109
+ }
110
+
111
+ function normalizeBinding(raw) {
112
+ if (!isObject(raw)) throw fail('模板绑定必须是对象', 400, 'binding');
113
+ onlyKeys(raw, ['mode', 'id', 'revision', 'resolvedVersions'], 'binding');
114
+ if (!['auto', 'template', 'passthrough'].includes(raw.mode)) throw fail('模板绑定模式无效', 400, 'binding');
115
+ if (raw.mode === 'passthrough') {
116
+ if (raw.id !== undefined || raw.revision !== undefined || raw.resolvedVersions !== undefined) throw fail('passthrough 绑定不能包含模板字段', 400, 'binding');
117
+ return { mode: 'passthrough' };
118
+ }
119
+ if (raw.mode === 'auto') {
120
+ if (raw.id !== undefined || raw.revision !== undefined) throw fail('auto 绑定不能包含模板 id', 400, 'binding');
121
+ const resolved = raw.resolvedVersions ?? DEFAULT_AUTO_RESOLVED;
122
+ if (!isObject(resolved)) throw fail('auto 绑定版本无效', 400, 'binding');
123
+ const output = {};
124
+ for (const [protocol, ref] of Object.entries(resolved)) {
125
+ if (!['responses', 'anthropic'].includes(protocol)) throw fail('auto 绑定仅支持 responses 和 anthropic 固定版本', 400, 'binding');
126
+ if (!isObject(ref)) throw fail('auto 绑定版本无效', 400, 'binding');
127
+ onlyKeys(ref, ['id', 'revision'], 'binding');
128
+ if (!isText(ref.id) || !TEMPLATE_ID.test(ref.id.trim())) throw fail('auto 绑定版本无效', 400, 'binding');
129
+ if (!Number.isSafeInteger(ref.revision) || ref.revision < 1) throw fail('auto 绑定必须指定有效版本', 400, 'binding');
130
+ output[protocol] = { id: ref.id.trim(), revision: ref.revision };
131
+ }
132
+ if (!output.responses || !output.anthropic) throw fail('auto 绑定必须固定 responses 和 anthropic 版本', 400, 'binding');
133
+ return { mode: 'auto', resolvedVersions: output };
134
+ }
135
+ if (!isText(raw.id) || !TEMPLATE_ID.test(raw.id.trim())) throw fail('绑定模板 id 无效', 400, 'binding');
136
+ if (!Number.isInteger(raw.revision) || raw.revision < 1) throw fail('绑定模板 revision 必须是正整数', 400, 'binding');
137
+ return { mode: 'template', id: raw.id.trim(), revision: raw.revision };
138
+ }
139
+
140
+ function normalizeModelBindings(raw) {
141
+ if (!isObject(raw)) throw fail('模型绑定必须是对象', 400, 'modelBindings');
142
+ const output = {};
143
+ for (const [model, binding] of Object.entries(raw)) {
144
+ if (!isText(model) || BAD_KEYS.has(model)) throw fail('模型绑定名称无效', 400, 'modelBindings');
145
+ output[model] = normalizeBinding(binding);
146
+ }
147
+ return output;
148
+ }
149
+
150
+ function normalizeConnectionShape(raw) {
151
+ if (!isObject(raw)) throw fail('连接必须是对象', 400, 'connection');
152
+ onlyKeys(raw, ['id', 'name', 'enabled', 'revision', 'siteId', 'independent', 'baseURL', 'protocol', 'apiPaths', 'modelProtocols', 'models', 'auth', 'binding', 'modelBindings', 'secrets', 'newApiAccountId'], 'connection');
153
+ let id;
154
+ if (raw.id !== undefined && raw.id !== '') {
155
+ if (!isText(raw.id) || !STABLE_ID.test(raw.id.trim())) throw fail('连接 id 无效', 400, 'id');
156
+ id = raw.id.trim();
157
+ }
158
+ if (!isText(raw.name) || raw.name.trim().length > 200) throw fail('连接名称不能为空且不能超过 200 字符', 400, 'name');
159
+ const enabled = raw.enabled ?? true;
160
+ if (typeof enabled !== 'boolean') throw fail('连接启用状态无效', 400, 'enabled');
161
+ const revision = raw.revision ?? 1;
162
+ if (!Number.isInteger(revision) || revision < 1) throw fail('连接 revision 必须是正整数', 400, 'revision');
163
+ let siteId = raw.siteId ?? '';
164
+ if (typeof siteId !== 'string' || (siteId && !STABLE_ID.test(siteId))) throw fail('站点 id 无效', 400, 'siteId');
165
+ siteId = siteId.trim();
166
+ const independent = raw.independent ?? false;
167
+ if (typeof independent !== 'boolean') throw fail('独立站点标记无效', 400, 'independent');
168
+ if (independent && !siteId) throw fail('独立站点必须设置站点 id', 400, 'siteId');
169
+ const baseURL = normalizeBaseURL(raw.baseURL);
170
+ const protocol = raw.protocol ?? 'auto';
171
+ if (!['auto', ...PROTOCOLS].includes(protocol)) throw fail('连接协议无效', 400, 'protocol');
172
+
173
+ const apiPathsRaw = raw.apiPaths ?? {};
174
+ if (!isObject(apiPathsRaw)) throw fail('连接 API 路径必须是对象', 400, 'apiPaths');
175
+ const apiPaths = {};
176
+ for (const [operation, path] of Object.entries(apiPathsRaw)) {
177
+ if (BAD_KEYS.has(operation) || !PROTOCOLS.includes(operation)) throw fail('连接 API 路径包含未知协议', 400, 'apiPaths');
178
+ if (typeof path !== 'string' || !/^\/(?!\/)[A-Za-z0-9/_-]*$/.test(path)) throw fail('连接 API 路径无效', 400, 'apiPaths');
179
+ apiPaths[operation] = path;
180
+ }
181
+
182
+ const modelProtocolsRaw = raw.modelProtocols ?? {};
183
+ if (!isObject(modelProtocolsRaw)) throw fail('连接模型协议必须是对象', 400, 'modelProtocols');
184
+ const modelProtocols = {};
185
+ for (const [model, value] of Object.entries(modelProtocolsRaw)) {
186
+ if (!isText(model) || BAD_KEYS.has(model)) throw fail('连接模型协议名称无效', 400, 'modelProtocols');
187
+ if (!PROTOCOLS.includes(value)) throw fail('连接模型协议无效', 400, 'modelProtocols');
188
+ modelProtocols[model] = value;
189
+ }
190
+
191
+ const modelsRaw = raw.models ?? [];
192
+ if (!Array.isArray(modelsRaw) || modelsRaw.some((item) => !isText(item)) || new Set(modelsRaw).size !== modelsRaw.length) throw fail('连接模型列表无效', 400, 'models');
193
+
194
+ const authRaw = raw.auth ?? {};
195
+ if (!isObject(authRaw)) throw fail('连接认证配置必须是对象', 400, 'auth');
196
+ onlyKeys(authRaw, ['mode', 'key', 'value', 'source', 'provider', 'account'], 'auth');
197
+ const mode = authRaw.mode ?? 'api-key';
198
+ if (!AUTH_MODES.includes(mode)) throw fail('连接认证方式无效', 400, 'auth');
199
+ const source = authRaw.source ?? 'stored';
200
+ if (!AUTH_SOURCES.includes(source)) throw fail('连接认证来源无效', 400, 'auth');
201
+ for (const key of ['key', 'value', 'provider', 'account']) {
202
+ if (authRaw[key] !== undefined && typeof authRaw[key] !== 'string') throw fail('连接认证字段无效', 400, 'auth');
203
+ }
204
+ for (const key of ['key', 'value']) {
205
+ const value = authRaw[key] ?? '';
206
+ if (/[\r\n\u0000]/.test(value) || value.length > 16384) throw fail('连接凭据格式无效', 400, 'auth');
207
+ }
208
+ let auth;
209
+ if (source === 'host') {
210
+ auth = { mode, key: '', value: '', source, provider: text(authRaw.provider), account: text(authRaw.account) };
211
+ } else {
212
+ auth = { mode, key: authRaw.key ?? '', value: authRaw.value ?? '', source };
213
+ }
214
+
215
+ const binding = raw.binding === undefined ? undefined : normalizeBinding(raw.binding);
216
+ const modelBindings = raw.modelBindings === undefined ? {} : normalizeModelBindings(raw.modelBindings);
217
+ const newApiAccountId = raw.newApiAccountId;
218
+ if (newApiAccountId !== undefined && (typeof newApiAccountId !== 'string' || newApiAccountId.length > 200 || /[\x00-\x1f\x7f-\x9f]/.test(newApiAccountId))) throw fail('余额与签到账号绑定无效', 400, 'newApiAccountId');
219
+
220
+ return {
221
+ id, name: raw.name.trim(), enabled, revision, siteId, independent, baseURL, protocol,
222
+ apiPaths, modelProtocols, models: [...modelsRaw], auth, binding, modelBindings,
223
+ ...(newApiAccountId !== undefined ? { newApiAccountId: newApiAccountId.trim() } : {}),
224
+ };
225
+ }
226
+
227
+ function mergeAuth(existing, incoming, clearSecrets) {
228
+ const auth = { ...incoming.auth };
229
+ // Host login owns the credential. provider/account are an identity reference,
230
+ // so changing them never carries a stored key forward.
231
+ if (auth.source === 'host') return { ...auth, key: '', value: '' };
232
+ const sameTarget = Boolean(existing) && existing.auth?.source === 'stored' &&
233
+ targetOf(existing.baseURL) !== null && targetOf(existing.baseURL) === targetOf(incoming.baseURL);
234
+ for (const field of ['key', 'value']) {
235
+ if (clearSecrets.includes(field)) auth[field] = '';
236
+ else if (!auth[field] && sameTarget) auth[field] = existing.auth?.[field] ?? '';
237
+ }
238
+ if (auth.mode === 'api-key' || auth.mode === 'bearer') auth.value = '';
239
+ else if (auth.mode === 'custom') auth.key = '';
240
+ else { auth.key = ''; auth.value = ''; }
241
+ if (auth.source === 'stored') {
242
+ if (['api-key', 'bearer'].includes(auth.mode) && !auth.key) throw fail('当前认证方式必须填写 key 凭据', 400, 'auth');
243
+ if (auth.mode === 'custom' && !auth.value) throw fail('该认证方式必须填写凭据', 400, 'auth');
244
+ }
245
+ return auth;
246
+ }
247
+
248
+ function publicConnection(connection, secrets) {
249
+ const output = structuredClone(connection);
250
+ const key = Boolean(output.auth?.key);
251
+ const value = Boolean(output.auth?.value);
252
+ output.secrets = { key, value };
253
+ if (!secrets && output.auth) {
254
+ output.auth.key = '';
255
+ output.auth.value = '';
256
+ }
257
+ return output;
258
+ }
259
+
260
+ function bindingProtocol(connection, model) {
261
+ if (model && connection.modelProtocols?.[model]) return connection.modelProtocols[model];
262
+ return connection.protocol === 'auto' ? undefined : connection.protocol;
263
+ }
264
+
265
+ function assertBindingResolvable(state, binding, protocol, { allowArchived = true } = {}) {
266
+ const checkTemplate = (id, revision, expectedProtocol) => {
267
+ const matches = state.templates.filter((item) => item.id === id && item.revision === revision);
268
+ if (matches.length !== 1) throw fail('模板版本不存在', 400, 'binding');
269
+ const template = matches[0];
270
+ if (template.archived && !allowArchived) throw fail('已归档模板不能应用', 400, 'binding');
271
+ if (expectedProtocol && !template.protocols.includes(expectedProtocol)) throw fail('模板协议与连接不匹配', 400, 'binding');
272
+ };
273
+ if (binding.mode === 'template') checkTemplate(binding.id, binding.revision, protocol);
274
+ else if (binding.mode === 'auto') {
275
+ const entries = Object.entries(binding.resolvedVersions);
276
+ for (const [name, ref] of entries) checkTemplate(ref.id, ref.revision, name);
277
+ } else if (binding.mode !== 'passthrough') throw fail('模板绑定模式无效', 400, 'binding');
278
+ }
279
+
280
+ function validateConnectionBindings(state, connection) {
281
+ if (!connection.binding) throw fail('连接缺少模板绑定', 400, 'binding');
282
+ assertBindingResolvable(state, connection.binding, bindingProtocol(connection));
283
+ for (const [model, binding] of Object.entries(connection.modelBindings ?? {})) {
284
+ assertBindingResolvable(state, binding, bindingProtocol(connection, model));
285
+ }
286
+ }
287
+
288
+ function validateSites(connections) {
289
+ const bases = new Map();
290
+ for (const connection of connections) {
291
+ if (!connection.siteId) continue;
292
+ const target = targetOf(connection.baseURL);
293
+ if (bases.has(connection.siteId) && bases.get(connection.siteId) !== target) throw fail('同一站点的 Base URL 必须一致', 400, 'siteId');
294
+ bases.set(connection.siteId, target);
295
+ }
296
+ const independent = new Set(connections.filter((item) => item.independent).map((item) => item.siteId));
297
+ if (independent.size > 8) throw fail('独立站点最多 8 个', 400, 'independent');
298
+ }
299
+
300
+ function normalizeState(raw) {
301
+ try {
302
+ if (!isObject(raw)) throw new Error();
303
+ onlyKeys(raw, ['schemaVersion', 'generation', 'templates', 'connections'], 'store');
304
+ if (raw.schemaVersion !== SCHEMA_VERSION) throw new Error();
305
+ if (!Number.isInteger(raw.generation) || raw.generation < 0) throw new Error();
306
+ if (!Array.isArray(raw.templates) || !Array.isArray(raw.connections)) throw new Error();
307
+ const templates = raw.templates.map((item) => governTemplate(item));
308
+ const versions = new Set();
309
+ for (const template of templates) {
310
+ const key = `${template.id}@${template.revision}`;
311
+ if (versions.has(key)) throw new Error();
312
+ versions.add(key);
313
+ }
314
+ const connections = raw.connections.map((item) => normalizeConnectionShape(item));
315
+ const ids = new Set();
316
+ for (const connection of connections) {
317
+ if (!connection.id || ids.has(connection.id)) throw new Error();
318
+ ids.add(connection.id);
319
+ validateConnectionBindings({ templates, connections }, connection);
320
+ }
321
+ validateSites(connections);
322
+ return { schemaVersion: SCHEMA_VERSION, generation: raw.generation, templates, connections };
323
+ } catch {
324
+ throw fail('状态文件内容无效,原文件未被修改', 500, 'store');
325
+ }
326
+ }
327
+
328
+ function emptyState() {
329
+ return { schemaVersion: SCHEMA_VERSION, generation: 0, templates: [], connections: [] };
330
+ }
331
+
332
+ export function createUAStore(path, { io = fs, uuid = randomUUID } = {}) {
333
+ if (!isAbsolute(path)) throw new Error('UA PRO 状态文件必须使用绝对路径');
334
+ const queueKey = process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path);
335
+
336
+ function enqueue(action) {
337
+ const result = (writes.get(queueKey) ?? Promise.resolve()).then(action);
338
+ const next = result.catch(() => {});
339
+ writes.set(queueKey, next);
340
+ next.finally(() => { if (writes.get(queueKey) === next) writes.delete(queueKey); });
341
+ return result;
342
+ }
343
+
344
+ async function load() {
345
+ let info;
346
+ try {
347
+ info = await io.lstat(path);
348
+ } catch (error) {
349
+ if (error.code === 'ENOENT') return null;
350
+ throw fail('无法读取状态文件,请检查文件权限', 500, 'store');
351
+ }
352
+ if (!info.isFile() || info.isSymbolicLink()) throw fail('状态文件必须是普通文件,不能使用符号链接', 500, 'store');
353
+ if (info.size > MAX_STORE_BYTES) throw fail('状态文件过大', 500, 'store');
354
+ let content;
355
+ try {
356
+ content = await io.readFile(path, 'utf8');
357
+ } catch {
358
+ throw fail('无法读取状态文件,请检查文件权限', 500, 'store');
359
+ }
360
+ if (Buffer.byteLength(content) > MAX_STORE_BYTES) throw fail('状态文件过大', 500, 'store');
361
+ let parsed;
362
+ try {
363
+ parsed = JSON.parse(content);
364
+ } catch {
365
+ throw fail('状态文件内容无效,原文件未被修改', 500, 'store');
366
+ }
367
+ return normalizeState(parsed);
368
+ }
369
+
370
+ async function persist(state) {
371
+ const content = `${JSON.stringify(state, null, 2)}\n`;
372
+ if (Buffer.byteLength(content) > MAX_STORE_BYTES) throw fail('状态文件过大', 400, 'store');
373
+ const folder = dirname(path);
374
+ const temporary = join(folder, `.ua-store-${uuid()}.tmp`);
375
+ let handle;
376
+ try {
377
+ await io.mkdir(folder, { recursive: true, mode: 0o700 });
378
+ handle = await io.open(temporary, 'wx', 0o600);
379
+ await handle.writeFile(content, 'utf8');
380
+ await handle.sync();
381
+ await handle.close();
382
+ handle = undefined;
383
+ await io.rename(temporary, path);
384
+ } catch {
385
+ throw fail('状态保存失败,请检查目录权限与可用空间;原状态保留', 500, 'store');
386
+ } finally {
387
+ if (handle) await handle.close().catch(() => {});
388
+ await io.unlink(temporary).catch(() => {});
389
+ }
390
+ }
391
+
392
+ async function update(action) {
393
+ return enqueue(async () => {
394
+ const loaded = await load();
395
+ const state = loaded ?? emptyState();
396
+ const outcome = await action(state);
397
+ if (outcome.changed) {
398
+ state.generation = (loaded?.generation ?? 0) + 1;
399
+ await persist(state);
400
+ }
401
+ return typeof outcome.value === 'function' ? outcome.value(state) : outcome.value;
402
+ });
403
+ }
404
+
405
+ async function initialize() {
406
+ return enqueue(async () => {
407
+ const loaded = await load();
408
+ const state = loaded ?? emptyState();
409
+ const seeds = builtinTemplates();
410
+ if (!Array.isArray(seeds) || seeds.length === 0) throw fail('内置模板数据无效', 500, 'builtinTemplates');
411
+ const created = [];
412
+ for (const seed of seeds) {
413
+ const template = governTemplate(seed);
414
+ if (![...BUILTIN_IDS, ...SEED_CUSTOM_IDS].includes(template.id)) throw fail('内置模板 id 无效', 500, 'builtinTemplates');
415
+ if (state.templates.some((item) => item.id === template.id && item.revision === template.revision)) continue;
416
+ state.templates.push(template);
417
+ created.push({ id: template.id, revision: template.revision });
418
+ }
419
+ if (created.length === 0) return { generation: state.generation, created, changed: false };
420
+ state.generation = (loaded?.generation ?? 0) + 1;
421
+ await persist(state);
422
+ return { generation: state.generation, created, changed: true };
423
+ });
424
+ }
425
+
426
+ async function snapshot({ secrets = false } = {}) {
427
+ return enqueue(async () => {
428
+ const state = (await load()) ?? emptyState();
429
+ return {
430
+ schemaVersion: SCHEMA_VERSION,
431
+ generation: state.generation,
432
+ templates: state.templates.map((item) => publicTemplate(item)),
433
+ connections: state.connections.map((item) => publicConnection(item, secrets === true)),
434
+ };
435
+ });
436
+ }
437
+
438
+ async function saveTemplate({ template: raw, expectedRevision } = {}) {
439
+ if (!isObject(raw)) throw fail('模板必须是对象', 400, 'template');
440
+ return update((state) => {
441
+ const id = isText(raw.id) ? raw.id.trim() : `custom.${uuid()}`;
442
+ if (id.startsWith('builtin.')) throw fail('内置模板只读,不能保存', 400, 'template');
443
+ const history = state.templates.filter((item) => item.id === id);
444
+ if (history.some((item) => item.kind === 'builtin')) throw fail('内置模板只读,不能保存', 400, 'template');
445
+ const latest = history.reduce((max, item) => Math.max(max, item.revision), 0);
446
+ if (expectedRevision !== undefined && expectedRevision !== latest) throw fail('模板已被其他窗口更新,请重新读取', 409, 'expectedRevision');
447
+ const previous = history.find((item) => item.revision === latest);
448
+ const template = governTemplate(raw, {
449
+ id,
450
+ revision: latest > 0 ? latest + 1 : 1,
451
+ });
452
+ template.archived = raw.archived ?? previous?.archived ?? false;
453
+ if (template.kind !== 'custom') throw fail('内置模板只读,不能保存', 400, 'template');
454
+ if (template.basedOn) assertTemplateRef(state.templates, template.basedOn.id, template.basedOn.revision);
455
+ state.templates.push(template);
456
+ return {
457
+ changed: true,
458
+ value: (next) => ({ template: publicTemplate(template), generation: next.generation }),
459
+ };
460
+ });
461
+ }
462
+
463
+ async function archiveTemplate({ id, archived = true } = {}) {
464
+ return update((state) => {
465
+ if (!isText(id)) throw fail('模板 id 缺失', 400, 'id');
466
+ const templateId = id.trim();
467
+ const revisions = state.templates.filter((item) => item.id === templateId);
468
+ if (revisions.length === 0) throw fail('模板不存在', 404, 'id');
469
+ if (revisions[0].kind !== 'custom') throw fail('内置模板只读,不能归档', 400, 'id');
470
+ if (typeof archived !== 'boolean') throw fail('archived 必须是布尔值', 400, 'archived');
471
+ let changed = false;
472
+ for (const revision of revisions) {
473
+ if (revision.archived !== archived) {
474
+ revision.archived = archived;
475
+ changed = true;
476
+ }
477
+ }
478
+ return {
479
+ changed,
480
+ value: (next) => ({ id: templateId, archived, generation: next.generation }),
481
+ };
482
+ });
483
+ }
484
+
485
+ function referencedTemplate(state, id, revision) {
486
+ const matches = (binding) => {
487
+ if (binding?.mode === 'template') return binding.id === id && (revision === undefined || binding.revision === revision);
488
+ if (binding?.mode === 'auto') {
489
+ return Object.values(binding.resolvedVersions ?? {}).some((ref) => ref?.id === id && (revision === undefined || ref.revision === revision));
490
+ }
491
+ return false;
492
+ };
493
+ return state.connections.some((connection) => matches(connection.binding) || Object.values(connection.modelBindings ?? {}).some(matches));
494
+ }
495
+
496
+ async function removeTemplate({ id, revision } = {}) {
497
+ return update((state) => {
498
+ if (!isText(id)) throw fail('模板 id 缺失', 400, 'id');
499
+ const templateId = id.trim();
500
+ const all = state.templates.filter((item) => item.id === templateId);
501
+ if (all.length === 0) throw fail('模板不存在', 404, 'id');
502
+ if (all[0].kind !== 'custom') throw fail('内置模板只读,不能删除', 400, 'id');
503
+ if (revision !== undefined && (!Number.isInteger(revision) || revision < 1)) throw fail('模板 revision 无效', 400, 'revision');
504
+ const targets = revision === undefined ? all : all.filter((item) => item.revision === revision);
505
+ if (targets.length === 0) throw fail('模板不存在', 404, 'id');
506
+ // Deleting the seeded revision would make service.initialize resurrect it on
507
+ // the next request; the seed revision is archive-only.
508
+ if (targets.some((item) => PROTECTED_SEED_VERSIONS.has(`${item.id}@${item.revision}`))) {
509
+ throw fail('内置种子模板版本不能删除,请使用归档', 400, 'id');
510
+ }
511
+ if (referencedTemplate(state, templateId, revision)) throw fail('模板版本正被连接使用,不能删除', 409, 'id');
512
+ state.templates = state.templates.filter((item) => !targets.includes(item));
513
+ return {
514
+ changed: true,
515
+ value: (next) => ({ id: templateId, removed: targets.map((item) => item.revision), generation: next.generation }),
516
+ };
517
+ });
518
+ }
519
+
520
+ async function prepareConnection({ connection: raw, expectedRevision, clearSecrets = [] } = {}) {
521
+ if (!isObject(raw)) throw fail('连接必须是对象', 400, 'connection');
522
+ if (!Array.isArray(clearSecrets) || clearSecrets.some(field => !['key', 'value'].includes(field))) throw fail('clearSecrets 无效', 400, 'clearSecrets');
523
+ const shape = normalizeConnectionShape({ ...raw, name: isText(raw.name) ? raw.name : '未保存连接', models: [], modelProtocols: {}, binding: undefined, modelBindings: {}, siteId: '', independent: false });
524
+ return enqueue(async () => {
525
+ const state = await load();
526
+ const existing = shape.id ? state?.connections.find(item => item.id === shape.id) : undefined;
527
+ if (shape.id && !existing) throw fail('连接已移除,请重新读取', 404, 'connection');
528
+ const revision = expectedRevision ?? raw.revision;
529
+ if (existing && revision !== undefined && revision !== existing.revision) throw fail('连接已被其他窗口更新,请重新读取', 409, 'expectedRevision');
530
+ if (shape.auth.source === 'host') {
531
+ if (!existing || existing.auth.source !== 'host' || shape.auth.provider !== existing.auth.provider || shape.auth.account !== existing.auth.account || shape.auth.mode !== existing.auth.mode) throw fail('宿主账号引用已改变,请重新读取连接', 409, 'auth');
532
+ if (targetOf(shape.baseURL) !== targetOf(existing.baseURL)) throw fail('宿主账号地址已改变,请重新建立对应连接', 409, 'baseURL');
533
+ if (clearSecrets.length) throw fail('宿主凭据已标记清除,不能用于查询', 400, 'auth');
534
+ }
535
+ return { ...shape, auth: mergeAuth(existing, shape, clearSecrets) };
536
+ });
537
+ }
538
+
539
+ async function saveConnection({ connection: raw, expectedRevision, clearSecrets = [] } = {}) {
540
+ if (!isObject(raw)) throw fail('连接必须是对象', 400, 'connection');
541
+ if (!Array.isArray(clearSecrets) || clearSecrets.some((field) => !['key', 'value'].includes(field))) throw fail('clearSecrets 无效', 400, 'clearSecrets');
542
+ return update((state) => {
543
+ const shape = normalizeConnectionShape(raw);
544
+ const existing = shape.id ? state.connections.find((item) => item.id === shape.id) : undefined;
545
+ if (!shape.id) shape.id = `connection.${uuid()}`;
546
+ if (!existing && expectedRevision !== undefined && expectedRevision !== 0) throw fail('连接已被其他窗口更新,请重新读取', 409, 'expectedRevision');
547
+ if (existing) {
548
+ if (expectedRevision !== undefined && expectedRevision !== existing.revision) throw fail('连接已被其他窗口更新,请重新读取', 409, 'expectedRevision');
549
+ shape.revision = existing.revision + 1;
550
+ shape.binding = structuredClone(existing.binding);
551
+ shape.modelBindings = structuredClone(existing.modelBindings ?? {});
552
+ } else {
553
+ shape.revision = 1;
554
+ shape.binding = shape.binding ?? { mode: 'auto', resolvedVersions: structuredClone(DEFAULT_AUTO_RESOLVED) };
555
+ }
556
+ shape.auth = mergeAuth(existing, shape, clearSecrets);
557
+ const connections = existing
558
+ ? state.connections.map((item) => (item.id === shape.id ? shape : item))
559
+ : [...state.connections, shape];
560
+ validateSites(connections);
561
+ validateConnectionBindings({ templates: state.templates }, shape);
562
+ state.connections = connections;
563
+ return {
564
+ changed: true,
565
+ value: (next) => ({ connection: publicConnection(shape, false), generation: next.generation }),
566
+ };
567
+ });
568
+ }
569
+
570
+ async function removeConnection({ id, expectedRevision } = {}) {
571
+ return update((state) => {
572
+ if (!isText(id)) throw fail('连接 id 缺失', 400, 'id');
573
+ const connectionId = id.trim();
574
+ const existing = state.connections.find((item) => item.id === connectionId);
575
+ if (!existing) throw fail('连接不存在', 404, 'id');
576
+ if (expectedRevision !== undefined && expectedRevision !== existing.revision) throw fail('连接已被其他窗口更新,请重新读取', 409, 'expectedRevision');
577
+ state.connections = state.connections.filter((item) => item.id !== connectionId);
578
+ return { changed: true, value: (next) => ({ id: connectionId, generation: next.generation }) };
579
+ });
580
+ }
581
+
582
+ async function applyBinding({ connectionIds, binding, model, expectedGeneration } = {}) {
583
+ return update((state) => {
584
+ if (!Number.isInteger(expectedGeneration) || expectedGeneration !== state.generation) throw fail('配置已被其他窗口更新,请重新读取', 409, 'expectedGeneration');
585
+ if (!Array.isArray(connectionIds) || connectionIds.length === 0) throw fail('必须选择至少一个连接', 400, 'connectionIds');
586
+ if (connectionIds.some((id) => !isText(id))) throw fail('连接 id 无效', 400, 'connectionIds');
587
+ const ids = connectionIds.map((id) => id.trim());
588
+ if (new Set(ids).size !== ids.length) throw fail('连接不能重复', 400, 'connectionIds');
589
+ if (model !== undefined && !isText(model)) throw fail('模型名称无效', 400, 'model');
590
+ const normalized = normalizeBinding(binding);
591
+ const targets = ids.map((id) => {
592
+ const connection = state.connections.find((item) => item.id === id);
593
+ if (!connection) throw fail('连接不存在', 404, 'connectionIds');
594
+ return connection;
595
+ });
596
+ // A connection protocol of 'auto' without a model override cannot prove one
597
+ // template matches every possible target. The binding is still accepted;
598
+ // resolveTemplate/transform reject a mismatching final protocol per request.
599
+ for (const connection of targets) assertBindingResolvable(state, normalized, bindingProtocol(connection, model?.trim()), { allowArchived: false });
600
+ for (const connection of targets) {
601
+ if (model !== undefined) connection.modelBindings = { ...connection.modelBindings, [model.trim()]: structuredClone(normalized) };
602
+ else connection.binding = structuredClone(normalized);
603
+ connection.revision += 1;
604
+ }
605
+ return {
606
+ changed: true,
607
+ value: (next) => ({ generation: next.generation, connections: targets.map((item) => publicConnection(item, false)) }),
608
+ };
609
+ });
610
+ }
611
+
612
+ async function applyModelRoute({ connectionId, model, protocol, binding, expectedGeneration } = {}) {
613
+ return update(state => {
614
+ if (!Number.isInteger(expectedGeneration) || expectedGeneration !== state.generation) throw fail('配置已被其他窗口更新,请重新读取', 409, 'expectedGeneration');
615
+ if (!isText(connectionId) || !isText(model) || BAD_KEYS.has(model.trim()) || model.trim().length > 256 || /[\x00-\x1f\x7f-\x9f]/.test(model) || !PROTOCOLS.includes(protocol)) throw fail('模型路由参数无效', 400, 'model');
616
+ const existing = state.connections.find(item => item.id === connectionId);
617
+ if (!existing) throw fail('连接不存在', 404, 'connectionId');
618
+ const normalized = normalizeBinding(binding);
619
+ const target = { ...existing, revision: existing.revision + 1, modelProtocols: { ...existing.modelProtocols, [model.trim()]: protocol }, modelBindings: { ...existing.modelBindings, [model.trim()]: normalized } };
620
+ assertBindingResolvable(state, normalized, protocol, { allowArchived: false });
621
+ validateConnectionBindings(state, target);
622
+ state.connections = state.connections.map(item => item.id === connectionId ? target : item);
623
+ return { changed: true, value: next => ({ generation: next.generation, connection: publicConnection(target, false) }) };
624
+ });
625
+ }
626
+
627
+ return {
628
+ path, initialize, snapshot, saveTemplate, archiveTemplate, removeTemplate,
629
+ prepareConnection, saveConnection, removeConnection, applyBinding, applyModelRoute,
630
+ };
631
+ }
632
+
633
+ export function resolveTemplate(snapshot, connection, protocol, model) {
634
+ if (!isObject(snapshot) || !Array.isArray(snapshot.templates)) throw fail('状态快照无效', 400, 'snapshot');
635
+ if (!PROTOCOLS.includes(protocol)) throw fail('未知上游协议', 400, 'protocol');
636
+ if (!isObject(connection)) throw fail('连接无效', 400, 'connection');
637
+ const binding = model && isObject(connection.modelBindings) && Object.hasOwn(connection.modelBindings, model)
638
+ ? connection.modelBindings[model]
639
+ : connection.binding;
640
+ if (!isObject(binding)) throw fail('连接缺少模板绑定', 400, 'binding');
641
+ if (binding.mode === 'passthrough') return null;
642
+ if (binding.mode === 'auto') {
643
+ const ref = binding.resolvedVersions?.[protocol];
644
+ if (!ref) {
645
+ if (protocol === 'chat-completions' || protocol === 'gemini') return null;
646
+ throw fail('auto 绑定缺少协议版本', 400, 'binding');
647
+ }
648
+ return findTemplate(snapshot, ref, protocol);
649
+ }
650
+ if (binding.mode === 'template') return findTemplate(snapshot, binding, protocol);
651
+ throw fail('模板绑定模式无效', 400, 'binding');
652
+ }
653
+
654
+ function findTemplate(snapshot, ref, protocol) {
655
+ if (!isObject(ref) || !isText(ref.id) || !Number.isInteger(ref.revision)) throw fail('模板版本缺失或损坏,拒绝回退', 400, 'binding');
656
+ const matches = snapshot.templates.filter((item) => item.id === ref.id && item.revision === ref.revision);
657
+ if (matches.length !== 1) throw fail('模板版本缺失或损坏,拒绝回退', 400, 'binding');
658
+ if (!Array.isArray(matches[0].protocols) || !matches[0].protocols.includes(protocol)) throw fail('模板协议不匹配', 400, 'protocol');
659
+ return resolvedTemplate(matches[0]);
660
+ }