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.
package/src/plugin.mjs ADDED
@@ -0,0 +1,344 @@
1
+ import { createProfileStore } from './store.mjs';
2
+ import { startSetupServer } from './setup-server.mjs';
3
+ import { resolveProfilePath } from './runtime.mjs';
4
+ import { createUAStore } from './ua-store.mjs';
5
+ import { createUAService } from './ua-service.mjs';
6
+ import { createTransport } from './transport.mjs';
7
+ import { normalizeProfile } from './profile.mjs';
8
+ import { connectionProfile, createModelDiscovery, modelRecords, AUTH_MARKER } from './connection.mjs';
9
+ import { randomBytes, randomUUID } from 'node:crypto';
10
+ import { readFile } from 'node:fs/promises';
11
+ import { dirname, join } from 'node:path';
12
+ import { createAccountManager } from './accounts.mjs';
13
+ import { ensureCheckinScheduler } from './scheduler.mjs';
14
+ import { createConnectionDiagnostics } from './diagnostics.mjs';
15
+ import { createAccountProfileStore } from './account-profile.mjs';
16
+ import { isDeepStrictEqual } from 'node:util';
17
+ import { pluginOptions, optionProfile } from './options.mjs';
18
+ import { accountUsage, accountCheckin } from './account-usage.mjs';
19
+
20
+ export const PROVIDER_ID = 'claude-code-ua';
21
+ function modelsFor(profile) {
22
+ return modelRecords(profile);
23
+ }
24
+
25
+ function npmFor(profile) {
26
+ return '@ai-sdk/anthropic';
27
+ }
28
+
29
+ function waitForSetup(session, signal) {
30
+ if (signal?.aborted) return Promise.resolve({ status: 'cancelled' });
31
+ if (!signal) return session.finished;
32
+ return new Promise((resolve, reject) => {
33
+ const abort = () => resolve({ status: 'cancelled' });
34
+ signal.addEventListener('abort', abort, { once: true });
35
+ session.finished.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
36
+ });
37
+ }
38
+
39
+ export async function createPlugin(input, rawOptions, dependencies = {}) {
40
+ const options = pluginOptions(rawOptions);
41
+ const store = dependencies.store ?? createProfileStore(resolveProfilePath({ configPath: options.profileFile }));
42
+ const startSetup = dependencies.startSetup ?? startSetupServer;
43
+ const accountManager = dependencies.accountManager ?? createAccountManager({ path: join(dirname(store.path), 'accounts.json'), fetchImpl: dependencies.accountFetch ?? globalThis.fetch });
44
+ const seed = optionProfile(options);
45
+ if (input?.magpie) {
46
+ const startScheduler = dependencies.startScheduler ?? ensureCheckinScheduler;
47
+ startScheduler({ accountManager, stateDirectory: join(dirname(store.path), 'schedule-claims'),
48
+ onError: (message) => input.client?.app?.log?.({ body: { service: 'claude-code-ua', level: 'warn', message } }),
49
+ });
50
+ }
51
+ const discoverModels = createModelDiscovery({ fetchImpl: dependencies.fetchImpl ?? globalThis.fetch });
52
+ const checkConnection = createConnectionDiagnostics({ fetchImpl: dependencies.fetchImpl ?? globalThis.fetch });
53
+ const readProfile = async () => {
54
+ const profile = await store.load();
55
+ return profile ? normalizeProfile(profile) : null;
56
+ };
57
+ let declaredProvider = options.baseURL ? { api: seed.upstream.baseUrl } : {};
58
+ let hostModelsExplicit = false;
59
+ let generatedModels = {};
60
+ let activeSetup;
61
+ let startingSetup;
62
+ const contextHeader = `x-magpie-profile-context-${randomBytes(12).toString('hex')}`;
63
+ const contexts = new Map();
64
+
65
+ function extractHostContext(headers) {
66
+ const token = headers.get(contextHeader);
67
+ headers.delete(contextHeader);
68
+ const saved = contexts.get(token);
69
+ contexts.delete(token);
70
+ return saved?.value ?? {};
71
+ }
72
+
73
+ async function recordConfigured() {
74
+ if (!input?.client?.auth?.set) return;
75
+ await input.client.auth.set({ path: { id: PROVIDER_ID }, body: { type: 'api', key: AUTH_MARKER, metadata: { profileFile: store.path } } });
76
+ }
77
+
78
+ async function boundAccount(getAuth) {
79
+ const auth = typeof getAuth === 'function' ? await getAuth() : null;
80
+ const fromAuth = auth?.metadata?.newApiAccountId;
81
+ if (typeof fromAuth === 'string') return fromAuth.trim() || null;
82
+ const slot = input?.magpie?.account?.(PROVIDER_ID);
83
+ return typeof slot === 'string' && Object.hasOwn(options.accountBindings, slot) ? options.accountBindings[slot] : null;
84
+ }
85
+
86
+ async function currentHostAuth(account = input?.magpie?.account?.(PROVIDER_ID) ?? PROVIDER_ID) {
87
+ try {
88
+ const all = dependencies.readHostAuth ? await dependencies.readHostAuth() : input?.directory ? JSON.parse(await readFile(join(input.directory, 'plugin-auth.json'), 'utf8')) : {};
89
+ return all[account] ? structuredClone(all[account]) : null;
90
+ } catch (error) {
91
+ if (error.code === 'ENOENT') return null;
92
+ throw new Error('无法读取当前登录信息,已停止操作以保留原凭据');
93
+ }
94
+ }
95
+
96
+ async function ensureAuthMarker() {
97
+ if (!input?.client?.auth?.set) return;
98
+ let auth;
99
+ try {
100
+ if (dependencies.readHostAuth) auth = await dependencies.readHostAuth();
101
+ else {
102
+ if (!input.directory) throw new Error();
103
+ auth = JSON.parse(await readFile(join(input.directory, 'plugin-auth.json'), 'utf8'));
104
+ }
105
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth)) throw new Error();
106
+ } catch (error) {
107
+ if (error.code === 'ENOENT') auth = {};
108
+ else throw new Error('无法检查宿主认证记录;拒绝启用 Provider,避免使用 SDK 默认上游');
109
+ }
110
+ if (!Object.hasOwn(auth, PROVIDER_ID)) await recordConfigured();
111
+ }
112
+
113
+ const uaStore = dependencies.uaStore ?? createUAStore(join(dirname(store.path), 'ua-pro.json'));
114
+ const uaService = dependencies.uaService ?? createUAService({
115
+ store: uaStore, legacyStore: store, accountManager,
116
+ readAuth: (provider, account) => currentHostAuth(account || provider),
117
+ fetchImpl: dependencies.fetchImpl ?? globalThis.fetch,
118
+ });
119
+
120
+ async function setupSession() {
121
+ if (activeSetup) return activeSetup;
122
+ if (startingSetup) return startingSetup;
123
+ startingSetup = startSetup({ store, accountManager, checkConnection, uaService, finishOnSave: false }).then((session) => {
124
+ activeSetup = session;
125
+ session.finished.finally(() => {
126
+ if (activeSetup === session) activeSetup = undefined;
127
+ }).catch(() => {});
128
+ return session;
129
+ }).finally(() => { startingSetup = undefined; });
130
+ return startingSetup;
131
+ }
132
+
133
+ // The retired first-load option remains accepted, but setup is now explicit.
134
+ // Opening management must never manufacture a signed-in legacy account.
135
+
136
+ return {
137
+ async config(config) {
138
+ const profile = await readProfile();
139
+ if (profile) await ensureAuthMarker();
140
+ config.provider ??= {};
141
+ hostModelsExplicit = Object.hasOwn(config.provider[PROVIDER_ID] ?? {}, 'models');
142
+ generatedModels = options.models ? structuredClone(options.models) : profile ? modelsFor(profile) : {};
143
+ const defaults = {
144
+ name: 'Claude Code Profile',
145
+ npm: npmFor(profile),
146
+ searches: options.searches ?? false,
147
+ models: structuredClone(generatedModels),
148
+ ...(profile ? { api: profile.upstream.baseUrl } : options.baseURL ? { api: seed.upstream.baseUrl } : {}),
149
+ };
150
+ config.provider[PROVIDER_ID] ??= {};
151
+ for (const [key, value] of Object.entries(defaults)) config.provider[PROVIDER_ID][key] ??= value;
152
+ declaredProvider = config.provider[PROVIDER_ID];
153
+ },
154
+ auth: {
155
+ provider: PROVIDER_ID,
156
+ options: [
157
+ { key: 'open-setup-on-first-load', label: '首次自动设置(已停用)', description: '兼容旧选项;请手动打开 UA PRO 统一管理。原生 API key 登录继续可用。', default: false },
158
+ ],
159
+ methods: [
160
+ {
161
+ type: 'api',
162
+ label: 'API key 连接(推荐)',
163
+ placeholder: '粘贴服务提供的 API key',
164
+ prompts: [{
165
+ type: 'text', key: 'baseURL', message: 'API 基础地址(已在 Provider 配置时可留空)', placeholder: '粘贴服务方提供的 Base URL',
166
+ validate(value) {
167
+ if (!String(value ?? '').trim()) return undefined;
168
+ try { connectionProfile(null, { type: 'api', key: 'validation-only', metadata: { endpoint: String(value) } }); return undefined; }
169
+ catch { return '请填写有效的 API 基础地址,不要填写 API key 或具体请求路径'; }
170
+ },
171
+ }],
172
+ },
173
+ {
174
+ type: 'oauth',
175
+ label: 'UA PRO 统一管理与旧配置迁移',
176
+ async authorize() {
177
+ const account = input?.magpie?.account?.(PROVIDER_ID) ?? PROVIDER_ID;
178
+ const originalAuth = await currentHostAuth(account);
179
+ const native = originalAuth?.type === 'api' && typeof originalAuth.key === 'string' && originalAuth.key.trim() && originalAuth.key !== AUTH_MARKER;
180
+ const accountStore = native ? createAccountProfileStore({
181
+ auth: originalAuth,
182
+ savedProfile: await readProfile(),
183
+ defaults: seed,
184
+ provider: declaredProvider,
185
+ path: input?.directory ? join(input.directory, 'plugin-auth.json') : store.path,
186
+ readAuth: () => currentHostAuth(account),
187
+ writeAuth: async (body) => {
188
+ if (!input?.client?.auth?.set) throw new Error('当前宿主不支持保存账号配置');
189
+ const response = await input.client.auth.set({ path: { id: account }, body });
190
+ if (response?.data !== true || !isDeepStrictEqual(await currentHostAuth(account), body)) throw new Error('宿主未确认账号配置已保存');
191
+ },
192
+ }) : null;
193
+ // The account store remains available only for backend compatibility clients.
194
+ const session = accountStore ? await startSetup({ store: accountStore, accountManager, checkConnection, uaService, finishOnSave: false }) : await setupSession();
195
+ return {
196
+ url: session.url,
197
+ method: 'auto',
198
+ opened: false,
199
+ instructions: '在 UA PRO 统一管理连接与模板;旧独立 Claude 配置可在“设置”中预览迁移。原生 API key 登录继续可用。管理页保存独立的 UA PRO 配置,完成后请返回 UA PRO 供应商并重载核对。Docker 或远程宿主请使用同端口 SSH 隧道访问。',
200
+ async callback(_code, options = {}) {
201
+ try {
202
+ const result = await waitForSetup(session, options.signal);
203
+ if (result.status === 'timeout') return { type: 'failed', error: 'UA PRO 管理已超时,已保存的配置仍保留' };
204
+ if (options.signal?.aborted || !['saved', 'managed', 'cancelled'].includes(result.status)) return { type: 'failed', error: 'UA PRO 管理已取消,原账号凭据保留' };
205
+ const expectedAuth = accountStore ? accountStore.getAuth() : originalAuth;
206
+ if (!isDeepStrictEqual(await currentHostAuth(account), expectedAuth)) return { type: 'failed', error: '账号已在其他位置变更,请重新打开配置;不会恢复旧凭据' };
207
+ // Management never returns a key: host save() would otherwise rebuild the record.
208
+ if (expectedAuth) return { type: 'success' };
209
+ return { type: 'failed', error: 'UA PRO 管理已关闭,已保存的更改保留;请使用 UA PRO 供应商。当前 Claude 兼容账号尚未登录,请使用 API key 连接。' };
210
+ } finally {
211
+ await new Promise((resolve) => setTimeout(resolve, 150));
212
+ if (session.retire) await session.retire();
213
+ else await session.close();
214
+ }
215
+ },
216
+ };
217
+ },
218
+ },
219
+ {
220
+ type: 'oauth',
221
+ label: '管理 New API 账号、余额与签到(可选)',
222
+ async authorize() {
223
+ const account = input?.magpie?.account?.(PROVIDER_ID) ?? PROVIDER_ID;
224
+ const originalAuth = await currentHostAuth(account);
225
+ const hostBinding = originalAuth ? { account, selected: originalAuth.metadata?.newApiAccountId ?? null, changed: false } : undefined;
226
+ const session = await startSetup({ store, accountManager, hostBinding, checkConnection, uaService });
227
+ return {
228
+ url: new URL('accounts', session.url).href,
229
+ method: 'auto',
230
+ instructions: '在账号管理页添加站点账号,刷新余额或签到。账号独立保存,完成后点击“完成并关闭”。这不会修改模型路由凭据。',
231
+ async callback(_code, options = {}) {
232
+ try {
233
+ const result = await waitForSetup(session, options.signal);
234
+ if (!['managed', 'saved'].includes(result.status)) return { type: 'failed', error: '账号管理已取消或超时,已保存的账号仍保留' };
235
+ let latest;
236
+ try { latest = await currentHostAuth(account); }
237
+ catch { return { type: 'failed', error: '无法读取当前账号,原凭据保留;请重新打开页面' }; }
238
+ if (!isDeepStrictEqual(latest, originalAuth)) {
239
+ return { type: 'failed', error: '当前账号已发生变化,请重新打开页面;不会覆盖现有凭据' };
240
+ }
241
+ if (!originalAuth) {
242
+ if (await readProfile()) return { type: 'success', key: AUTH_MARKER, metadata: { profileFile: store.path } };
243
+ return { type: 'failed', error: '站点账号已保存;聊天连接尚未设置,请使用 API key 连接' };
244
+ }
245
+ if (hostBinding.changed) {
246
+ if (!input?.client?.auth?.set) return { type: 'failed', error: '当前宿主不支持保存账号配置,绑定未保存' };
247
+ const nextAuth = { ...latest, metadata: { ...latest.metadata, newApiAccountId: hostBinding.selected ?? '' } };
248
+ let response;
249
+ try {
250
+ response = await input.client.auth.set({ path: { id: account }, body: structuredClone(nextAuth) });
251
+ } catch {
252
+ return { type: 'failed', error: '账号配置保存失败,请重试;原凭据保留' };
253
+ }
254
+ if (response?.data !== true) return { type: 'failed', error: '宿主未确认账号配置已保存,请重试' };
255
+ let confirmed;
256
+ try { confirmed = await currentHostAuth(account); }
257
+ catch { return { type: 'failed', error: '无法确认账号配置已保存,请重试' }; }
258
+ if (!isDeepStrictEqual(confirmed, nextAuth)) return { type: 'failed', error: '宿主未确认账号配置已保存,请重试' };
259
+ }
260
+ // The account already exists. Returning a key would make the host save() rebuild it.
261
+ return { type: 'success' };
262
+ } finally {
263
+ await new Promise((resolve) => setTimeout(resolve, 150));
264
+ if (session.retire) await session.retire();
265
+ else await session.close();
266
+ }
267
+ },
268
+ };
269
+ },
270
+ },
271
+ ],
272
+ async usage(getAuth) {
273
+ const id = await boundAccount(getAuth);
274
+ if (!id) return { signIn: 'kept', windows: [{ name: '可选余额', display: '当前宿主账号未绑定站点账号;聊天不受影响', aside: true }] };
275
+ let account;
276
+ try { account = await accountManager.refresh(id); }
277
+ catch { return { signIn: 'kept', windows: [{ name: '可选余额', display: '绑定的站点账号不可用,请检查账号管理', aside: true }] }; }
278
+ return accountUsage(account);
279
+ },
280
+ async checkin(getAuth) {
281
+ const id = await boundAccount(getAuth);
282
+ if (!id) return { outcome: 'inactive', message: '当前宿主账号未绑定站点账号,未发起签到' };
283
+ let account;
284
+ try {
285
+ account = await accountManager.checkin(id);
286
+ } catch { return { outcome: 'failed', message: '绑定的站点账号签到未完成,请检查账号管理' }; }
287
+ return accountCheckin(account);
288
+ },
289
+ async loader(getAuth, provider) {
290
+ const auth = await getAuth();
291
+ if (auth?.type !== 'api' || typeof auth.key !== 'string' || !auth.key.trim()) return {};
292
+ const getProfile = async () => {
293
+ const current = await getAuth();
294
+ if (current?.type !== 'api' || typeof current.key !== 'string' || !current.key.trim()) throw new Error('No API key available; sign in again');
295
+ return connectionProfile(await readProfile(), current, { ...declaredProvider, ...provider }, seed);
296
+ };
297
+ const profile = await getProfile();
298
+ return {
299
+ baseURL: profile.upstream.baseUrl,
300
+ apiKey: ['api-key', 'bearer'].includes(profile.upstream.authorizationMode) ? profile.upstream.apiKey : AUTH_MARKER,
301
+ fetch: createTransport({ getProfile, extractHostContext, fetchImpl: dependencies.fetchImpl ?? globalThis.fetch }),
302
+ };
303
+ },
304
+ },
305
+ provider: {
306
+ id: PROVIDER_ID,
307
+ async models(provider = {}, { auth } = {}) {
308
+ const saved = await readProfile();
309
+ if (!saved && !auth) return provider.models ?? {};
310
+ if (hostModelsExplicit) return provider.models ?? {};
311
+ const profile = connectionProfile(saved, auth, { ...declaredProvider, ...provider }, seed);
312
+ const models = await discoverModels(profile);
313
+ const records = modelRecords(profile, Object.keys(models), { ...models, ...options.models, ...provider.models });
314
+ const scopedUrl = auth?.metadata?.claudeProfile?.upstream?.baseUrl;
315
+ let scopedTarget = false;
316
+ try {
317
+ scopedTarget = auth?.type === 'api' && auth.key !== AUTH_MARKER && Boolean(auth.key) &&
318
+ new URL(scopedUrl).href.replace(/\/+$/, '') === new URL(profile.upstream.baseUrl).href.replace(/\/+$/, '');
319
+ } catch { /* No matching account-scoped configuration. */ }
320
+ for (const [id, model] of Object.entries(records)) {
321
+ const generated = generatedModels[id];
322
+ const generatedRoute = generated && model.api.url === generated.api?.url && model.api.npm === generated.api?.npm &&
323
+ model.provider.api === generated.provider?.api && model.provider.npm === generated.provider?.npm;
324
+ if (scopedTarget || generatedRoute) {
325
+ model.api = { ...model.api, url: profile.upstream.baseUrl, npm: npmFor(profile) };
326
+ model.provider = { ...model.provider, api: profile.upstream.baseUrl, npm: npmFor(profile) };
327
+ }
328
+ }
329
+ return records;
330
+ },
331
+ },
332
+ async 'chat.headers'(context, output) {
333
+ if (context?.model?.providerID !== PROVIDER_ID && context?.provider?.info?.id !== PROVIDER_ID) return;
334
+ const now = Date.now();
335
+ for (const [token, saved] of contexts) {
336
+ if (now - saved.created > 60000) contexts.delete(token);
337
+ }
338
+ if (contexts.size >= 2048) contexts.delete(contexts.keys().next().value);
339
+ const token = randomUUID();
340
+ contexts.set(token, { created: now, value: { sessionID: context.sessionID, model: context.model } });
341
+ output.headers[contextHeader] = token;
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,217 @@
1
+ import { compileParameters, isObject } from '../lib/parameters.mjs';
2
+
3
+ export const HEADER_LIMIT_BYTES = 16 * 1024;
4
+ export const PROTECTED_HEADER_NAMES = Object.freeze([
5
+ 'authorization', 'x-api-key', 'api-key', 'x-goog-api-key', 'cookie', 'set-cookie',
6
+ 'host', 'content-length', 'content-encoding', 'connection', 'transfer-encoding',
7
+ 'proxy-authorization', 'proxy-authenticate', 'proxy-connection', 'keep-alive', 'upgrade', 'te', 'trailer',
8
+ 'x-openai-actor-authorization',
9
+ ]);
10
+ export const VARIABLE_DEFINITIONS = Object.freeze([
11
+ Object.freeze({ name: 'session.id', description: '真实宿主会话 ID;缺失时省略' }),
12
+ Object.freeze({ name: 'request.id', description: '本次上游请求 ID;由插件生成' }),
13
+ Object.freeze({ name: 'client.version', description: '配置中的客户端版本' }),
14
+ Object.freeze({ name: 'model.id', description: '真实请求或宿主提供的模型 ID' }),
15
+ Object.freeze({ name: 'reasoning.effort', description: 'Messages output_config.effort 提供的推理强度' }),
16
+ Object.freeze({ name: 'timestamp', description: '本次请求的 Unix 毫秒时间戳' }),
17
+ ]);
18
+ export const DEFAULT_METADATA_TEMPLATES = Object.freeze({});
19
+ const BETA = 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,effort-2025-11-24,dangerous-tool-use-2026-09-03,afk-mode-2026-01-31';
20
+ const vscodeUA = 'claude-cli/2.1.316 (external, claude-vscode, agent-sdk/0.3.316)';
21
+ const presetHeaders = Object.freeze({
22
+ accept: 'application/json',
23
+ 'anthropic-beta': BETA,
24
+ 'anthropic-dangerous-direct-browser-access': 'true',
25
+ 'anthropic-version': '2023-06-01',
26
+ 'user-agent': vscodeUA,
27
+ 'x-app': 'cli',
28
+ 'x-stainless-arch': 'x64',
29
+ 'x-stainless-lang': 'js',
30
+ 'x-stainless-os': 'Windows',
31
+ 'x-stainless-package-version': '0.128.0',
32
+ 'x-stainless-retry-count': '0',
33
+ 'x-stainless-runtime': 'node',
34
+ 'x-stainless-runtime-version': 'v26.12.1',
35
+ 'x-stainless-timeout': '300',
36
+ });
37
+ export const CLIENT_PRESETS = Object.freeze({
38
+ 'claude-vscode': Object.freeze({ label: 'Claude Code VS Code', version: '2.1.316', userAgent: vscodeUA, headers: presetHeaders }),
39
+ 'claude-cli': Object.freeze({ label: 'Claude Code CLI', version: '2.1.316', userAgent: 'claude-cli/2.1.316 (external, cli)', headers: Object.freeze({ ...presetHeaders, 'user-agent': 'claude-cli/2.1.316 (external, cli)' }) }),
40
+ none: Object.freeze({ label: '无预设', version: '', userAgent: '', headers: Object.freeze({}) }),
41
+ });
42
+ export class ProfileValidationError extends Error {
43
+ constructor(fields, message = '配置验证失败') {
44
+ super(message); this.name = 'ProfileValidationError'; this.code = 'PROFILE_VALIDATION_ERROR'; this.fields = fields;
45
+ }
46
+ }
47
+ const protectedNames = new Set(PROTECTED_HEADER_NAMES);
48
+ const variables = new Set(VARIABLE_DEFINITIONS.map(v => v.name));
49
+ const token = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
50
+ export function isProtectedHeader(name) {
51
+ return protectedNames.has(name.toLowerCase()) || name.toLowerCase().startsWith('x-magpie-');
52
+ }
53
+ export function isSafeHeaderText(value) {
54
+ return typeof value === 'string' && !/[\u0000-\u001f\u007f]|[^\u0020-\u00ff]/u.test(value);
55
+ }
56
+ export function validateHeaderTemplate(value) {
57
+ if (!isSafeHeaderText(value) || value.length > HEADER_LIMIT_BYTES) return false;
58
+ let cursor = 0;
59
+ while (cursor < value.length) {
60
+ const open = value.indexOf('{{', cursor); const close = value.indexOf('}}', cursor);
61
+ if (open < 0 && close < 0) return true;
62
+ if (open < 0 || (close >= 0 && close < open)) return false;
63
+ const end = value.indexOf('}}', open + 2);
64
+ if (end < 0 || !variables.has(value.slice(open + 2, end))) return false;
65
+ cursor = end + 2;
66
+ }
67
+ return true;
68
+ }
69
+ export function targetOf(value) {
70
+ try {
71
+ const url = new URL(value);
72
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
73
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
74
+ } catch { return null; }
75
+ }
76
+ export function createDefaultProfile() {
77
+ return {
78
+ version: 1,
79
+ upstream: { baseUrl: '', apiMode: 'anthropic', apiKey: '', authorizationMode: 'api-key', authorizationValue: '' },
80
+ models: [], client: { preset: 'claude-vscode', version: '' },
81
+ headers: { userAgent: '', acceptMode: 'json', acceptValue: '', contentType: '', anthropicVersion: '', anthropicBeta: '', sessionId: '{{session.id}}', clientRequestId: '{{request.id}}', extra: [] },
82
+ parameters: { defaults: {}, overrides: {}, remove: [] },
83
+ };
84
+ }
85
+
86
+ /** allowBlank is for an unconfigured UI/config draft only; transport/store use strict validation. */
87
+ export function normalizeProfile(raw, { allowBlank = false } = {}) {
88
+ if (!isObject(raw)) throw new ProfileValidationError({ profile: '配置必须是对象' });
89
+ const fields = {}; const output = createDefaultProfile();
90
+ const error = (path, message) => { fields[path] ??= message; };
91
+ const known = (value, keys, path) => {
92
+ if (isObject(value) && Object.keys(value).some(key => !keys.includes(key))) error(path, '包含未知配置字段,请检查字段名称');
93
+ };
94
+ known(raw, ['version', 'upstream', 'models', 'client', 'headers', 'parameters'], 'profile');
95
+ known(raw.upstream, Object.keys(output.upstream), 'upstream');
96
+ known(raw.client, Object.keys(output.client), 'client');
97
+ known(raw.headers, Object.keys(output.headers), 'headers');
98
+ if (raw.version !== undefined && raw.version !== 1) error('version', '仅支持配置版本 1');
99
+ const upstream = raw.upstream;
100
+ if (upstream !== undefined && !isObject(upstream)) error('upstream', 'upstream 必须是对象');
101
+ const source = isObject(upstream) ? upstream : {};
102
+ const base = source.baseUrl ?? '';
103
+ if (typeof base !== 'string') error('upstream.baseUrl', '上游 Base URL 必须是字符串');
104
+ else if (!base.trim()) { if (!allowBlank) error('upstream.baseUrl', '上游 Base URL 不能为空'); }
105
+ else {
106
+ try {
107
+ const value = base.trim(); const url = new URL(value);
108
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
109
+ if (!/^https?:\/\/[^/?#]+/i.test(value) || !['http:', 'https:'].includes(url.protocol) ||
110
+ (url.protocol === 'http:' && !loopback) || url.username || url.password || value.includes('@') ||
111
+ value.includes('?') || value.includes('#') || /[\u0000-\u0020\u007f{}\\]/.test(value) ||
112
+ /\/(messages|responses|chat\/completions)\/?$/i.test(url.pathname)) throw new Error();
113
+ output.upstream.baseUrl = url.href.replace(/\/+$/, '');
114
+ } catch { error('upstream.baseUrl', '请填写 HTTPS Base URL(回环地址可用 HTTP),不要包含凭据、查询、片段或具体请求路径'); }
115
+ }
116
+ if (source.apiMode !== undefined && source.apiMode !== 'anthropic') error('upstream.apiMode', 'apiMode 必须是 anthropic');
117
+ for (const name of ['apiKey', 'authorizationValue']) {
118
+ if (source[name] === undefined) continue;
119
+ if (!isSafeHeaderText(source[name]) || source[name].length > HEADER_LIMIT_BYTES) error(`upstream.${name}`, '凭据必须是不含控制字符的 HTTP 头文本');
120
+ else output.upstream[name] = source[name];
121
+ }
122
+ if (source.authorizationMode !== undefined) {
123
+ if (!['api-key', 'bearer', 'custom', 'none'].includes(source.authorizationMode)) error('upstream.authorizationMode', '授权模式必须是 api-key、bearer、custom 或 none');
124
+ else output.upstream.authorizationMode = source.authorizationMode;
125
+ }
126
+ if (!allowBlank && ['api-key', 'bearer'].includes(output.upstream.authorizationMode) && !output.upstream.apiKey.trim()) error('upstream.apiKey', '当前授权模式必须填写 API key');
127
+ if (!allowBlank && output.upstream.authorizationMode === 'custom' && !output.upstream.authorizationValue.trim()) error('upstream.authorizationValue', 'custom 模式必须填写完整的 Authorization 值');
128
+ if (raw.models !== undefined) {
129
+ if (!Array.isArray(raw.models)) error('models', 'models 必须是字符串数组');
130
+ else {
131
+ const models = [];
132
+ raw.models.forEach((id, index) => {
133
+ if (typeof id !== 'string' || !id.trim() || /[\u0000-\u001f\u007f]/.test(id)) error(`models[${index}]`, '模型 ID 必须是不含控制字符的非空字符串');
134
+ else models.push(id.trim());
135
+ });
136
+ output.models = [...new Set(models)];
137
+ }
138
+ }
139
+ if (raw.client !== undefined && !isObject(raw.client)) error('client', 'client 必须是对象');
140
+ if (isObject(raw.client)) {
141
+ if (raw.client.preset !== undefined) {
142
+ if (typeof raw.client.preset !== 'string' || !Object.hasOwn(CLIENT_PRESETS, raw.client.preset)) error('client.preset', '请选择 claude-vscode、claude-cli 或 none');
143
+ else output.client.preset = raw.client.preset;
144
+ }
145
+ if (raw.client.version !== undefined) {
146
+ if (!isSafeHeaderText(raw.client.version)) error('client.version', '版本必须是不含控制字符的 HTTP 头文本');
147
+ else output.client.version = raw.client.version;
148
+ }
149
+ }
150
+ if (raw.headers !== undefined && !isObject(raw.headers)) error('headers', 'headers 必须是对象');
151
+ const headers = isObject(raw.headers) ? raw.headers : {};
152
+ for (const name of ['userAgent', 'acceptValue', 'contentType', 'anthropicVersion', 'anthropicBeta', 'sessionId', 'clientRequestId']) {
153
+ if (headers[name] === undefined) continue;
154
+ if (!validateHeaderTemplate(headers[name])) error(`headers.${name}`, '请求头文本或模板变量无效');
155
+ else output.headers[name] = headers[name];
156
+ }
157
+ if (headers.acceptMode !== undefined) {
158
+ if (!['json', 'auto', 'sse', 'custom'].includes(headers.acceptMode)) error('headers.acceptMode', 'Accept 模式必须是 json、auto、sse 或 custom');
159
+ else output.headers.acceptMode = headers.acceptMode;
160
+ }
161
+ if (output.headers.acceptMode === 'custom' && !output.headers.acceptValue.trim()) error('headers.acceptValue', 'custom 模式必须填写 Accept 值');
162
+ if (headers.extra !== undefined) {
163
+ if (!Array.isArray(headers.extra)) error('headers.extra', '附加请求头必须是数组');
164
+ else headers.extra.forEach((item, index) => {
165
+ const path = `headers.extra[${index}]`;
166
+ if (!isObject(item)) { error(path, '附加请求头必须是对象'); return; }
167
+ known(item, ['name', 'value', 'enabled'], path);
168
+ if (typeof item.name !== 'string' || !token.test(item.name) || isProtectedHeader(item.name)) error(`${path}.name`, '该请求头名称无效或受到保护');
169
+ if (item.value !== null && !validateHeaderTemplate(item.value)) error(`${path}.value`, '请求头值必须是有效文本、模板或 null');
170
+ if (item.enabled !== undefined && typeof item.enabled !== 'boolean') error(`${path}.enabled`, 'enabled 必须是布尔值');
171
+ output.headers.extra.push({ name: item.name, value: item.value, enabled: item.enabled ?? true });
172
+ });
173
+ }
174
+ try {
175
+ const plan = compileParameters(raw.parameters);
176
+ output.parameters = { defaults: plan.defaults, overrides: plan.overrides, remove: plan.remove.map(path => path.join('.')) };
177
+ } catch { error('parameters', 'Messages 参数设置无效,不能修改受保护字段或使用 Responses 参数'); }
178
+ if (Object.keys(fields).length) throw new ProfileValidationError(fields);
179
+ return output;
180
+ }
181
+
182
+ export function redactProfile(profile) {
183
+ let output;
184
+ try { output = normalizeProfile(profile, { allowBlank: true }); }
185
+ catch { output = createDefaultProfile(); }
186
+ output.upstream.apiKey = ''; output.upstream.authorizationValue = '';
187
+ return output;
188
+ }
189
+
190
+ export function mergeProfileSecrets(nextRaw, previousProfile, clearSecrets = []) {
191
+ if (!Array.isArray(clearSecrets) || clearSecrets.some(field => !['apiKey', 'authorizationValue'].includes(field))) throw new ProfileValidationError({ clearSecrets: 'clearSecrets 包含未知字段或格式无效' });
192
+ if (!isObject(nextRaw)) throw new ProfileValidationError({ profile: '配置必须是对象' });
193
+ const nextTarget = targetOf(nextRaw.upstream?.baseUrl); const oldTarget = targetOf(previousProfile?.upstream?.baseUrl);
194
+ const same = nextTarget !== null && nextTarget === oldTarget;
195
+ const draft = { ...nextRaw, upstream: isObject(nextRaw.upstream) ? { ...nextRaw.upstream } : nextRaw.upstream };
196
+ if (isObject(draft.upstream)) {
197
+ for (const field of ['apiKey', 'authorizationValue']) {
198
+ const value = draft.upstream[field];
199
+ if ((value === undefined || (typeof value === 'string' && !value.trim()))) draft.upstream[field] = same && !clearSecrets.includes(field) ? previousProfile?.upstream?.[field] ?? '' : '';
200
+ else if (!same && oldTarget && value === previousProfile?.upstream?.[field]) throw new ProfileValidationError({ [`upstream.${field}`]: '服务地址已改变,请重新填写该服务的凭据' });
201
+ if (clearSecrets.includes(field) && (value === undefined || value === '')) draft.upstream[field] = '';
202
+ }
203
+ }
204
+ const normalized = normalizeProfile(draft);
205
+ if (nextTarget && oldTarget && !same) {
206
+ const fields = {}; const defaults = createDefaultProfile().headers; const old = previousProfile?.headers ?? {};
207
+ for (const [key, value] of Object.entries(normalized.headers)) {
208
+ if (key === 'extra' || typeof value !== 'string' || value === defaults[key] || !value.trim()) continue;
209
+ if (typeof old[key] === 'string' && value.trim() === old[key].trim()) fields[`headers.${key}`] = '服务地址已改变,请移除旧服务的专用设置';
210
+ }
211
+ normalized.headers.extra.forEach((item, index) => {
212
+ if ((old.extra ?? []).some(entry => typeof entry?.name === 'string' && entry.name.toLowerCase() === item.name.toLowerCase() && entry.value === item.value)) fields[`headers.extra[${index}].value`] = '服务地址已改变,请移除旧服务的附加请求头';
213
+ });
214
+ if (Object.keys(fields).length) throw new ProfileValidationError(fields);
215
+ }
216
+ return normalized;
217
+ }
@@ -0,0 +1,39 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { spawn } from 'node:child_process';
4
+ export function resolveProfilePath({ configPath, env = process.env } = {}) {
5
+ const path = configPath ?? env.MAGPIE_CLAUDE_PROFILE_FILE ?? fileURLToPath(new URL('../runtime/profile.json', import.meta.url));
6
+ if (typeof path !== 'string' || !isAbsolute(path)) throw new Error('配置文件必须使用绝对路径');
7
+ return path;
8
+ }
9
+ export function parseSetupArgs(args) {
10
+ const result = { configPath: undefined, port: 0, open: true, help: false };
11
+ for (let index = 0; index < args.length; index++) {
12
+ const argument = args[index];
13
+ if (argument === '--config') {
14
+ const path = args[++index];
15
+ if (!path || path.startsWith('--') || !isAbsolute(path)) throw new Error('--config 必须填写绝对路径');
16
+ result.configPath = path;
17
+ } else if (argument === '--port') {
18
+ const value = args[++index];
19
+ if (!/^\d+$/.test(value ?? '') || Number(value) > 65535) throw new Error('--port 必须是 0 到 65535 的端口');
20
+ result.port = Number(value);
21
+ } else if (argument === '--no-open') result.open = false;
22
+ else if (argument === '--help' || argument === '-h') result.help = true;
23
+ else throw new Error('未知参数,请运行 --help 查看用法');
24
+ }
25
+ return result;
26
+ }
27
+ export function openBrowser(url, { platform = process.platform, launch = spawn } = {}) {
28
+ const parsed = new URL(url);
29
+ if (parsed.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(parsed.hostname) || parsed.username || parsed.password) return Promise.reject(new Error('只允许打开当前回环配置页面'));
30
+ const command = platform === 'win32' ? 'rundll32.exe' : platform === 'darwin' ? 'open' : 'xdg-open';
31
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
32
+ return new Promise(resolve => {
33
+ try {
34
+ const child = launch(command, args, { shell: false, detached: true, stdio: 'ignore' });
35
+ child.once('error', () => resolve(false));
36
+ child.once('spawn', () => { child.unref(); resolve(true); });
37
+ } catch { resolve(false); }
38
+ });
39
+ }