opencode-claude-code-ua 0.0.0-stage → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/NOTICE.md +9 -0
- package/README.md +196 -2
- package/assets/icon.svg +9 -0
- package/index.mjs +23 -0
- package/lib/parameters.mjs +108 -0
- package/lib/ua-protocol.mjs +43 -0
- package/lib/ua-test-plan.mjs +232 -0
- package/package.json +80 -4
- package/setup.mjs +39 -0
- package/src/account-profile.mjs +73 -0
- package/src/accounts.mjs +281 -0
- package/src/connection.mjs +144 -0
- package/src/diagnostics.mjs +37 -0
- package/src/headers.mjs +60 -0
- package/src/newapi.mjs +237 -0
- package/src/options.mjs +48 -0
- package/src/plugin.mjs +374 -0
- package/src/profile.mjs +217 -0
- package/src/runtime.mjs +39 -0
- package/src/schedule.mjs +88 -0
- package/src/scheduler.mjs +141 -0
- package/src/setup-server.mjs +465 -0
- package/src/store.mjs +58 -0
- package/src/transport.mjs +84 -0
- package/src/ua-model-catalog.mjs +218 -0
- package/src/ua-plugin.mjs +207 -0
- package/src/ua-runtime.mjs +229 -0
- package/src/ua-service.mjs +97 -0
- package/src/ua-store.mjs +657 -0
- package/src/ua-tests.mjs +153 -0
- package/src/ua-transform.mjs +478 -0
- package/ui/accounts.css +75 -0
- package/ui/accounts.html +57 -0
- package/ui/accounts.js +582 -0
- package/ui/pro.css +1682 -0
- package/ui/pro.html +348 -0
- package/ui/pro.js +830 -0
- package/ui/theme.js +39 -0
package/src/accounts.mjs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, unlink, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { AccountValidationError, createNewApiClient, NewApiError, normalizeAccountConfig, safeNewApiMessage } from './newapi.mjs';
|
|
5
|
+
import { scheduleOccurrence } from './schedule.mjs';
|
|
6
|
+
|
|
7
|
+
export { AccountValidationError } from './newapi.mjs';
|
|
8
|
+
const MAX_ACCOUNTS = 100;
|
|
9
|
+
const MAX_HISTORY = 90;
|
|
10
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
// All managers sharing a file share the transaction queue and network deduplication.
|
|
12
|
+
const sessions = new Map();
|
|
13
|
+
const freshStatus = () => ({ stale: false, error: null, lastAttemptAt: null });
|
|
14
|
+
const freshCheckin = () => ({ date: null, checkedAt: null, rewardQuota: null, state: 'unknown', message: '' });
|
|
15
|
+
const identity = a => JSON.stringify([a.siteUrl, a.userId]);
|
|
16
|
+
const missing = () => new AccountValidationError('账号不存在。', 404);
|
|
17
|
+
const own = (state, id) => { const account = state.accounts.find(a => a.id === id); if (!account) throw missing(); return account; };
|
|
18
|
+
const successful = result => ['success', 'already', 'already_checked'].includes(result?.state);
|
|
19
|
+
const day = value => new Date(value).toISOString().slice(0, 10);
|
|
20
|
+
const amount = (quota, divisor) => quota === null || quota === undefined || !Number.isFinite(quota / divisor) ? null : quota / divisor;
|
|
21
|
+
|
|
22
|
+
function projectSnapshot(snapshot, divisor) {
|
|
23
|
+
if (!snapshot) return null;
|
|
24
|
+
return { at: snapshot.at, quota: snapshot.quota, usedQuota: snapshot.usedQuota, requestCount: snapshot.requestCount,
|
|
25
|
+
remaining: amount(snapshot.quota, divisor), used: amount(snapshot.usedQuota, divisor),
|
|
26
|
+
deltaBalance: amount(snapshot.deltaQuota, divisor), deltaUsed: amount(snapshot.deltaUsedQuota, divisor),
|
|
27
|
+
deltaRequests: snapshot.deltaRequests, reset: snapshot.reset };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function publicAccount(account) {
|
|
31
|
+
const config = normalizeAccountConfig(account);
|
|
32
|
+
return { id: account.id, revision: account.revision, ...config, accessToken: '', cookie: '', secrets: { accessToken: Boolean(account.accessToken), cookie: Boolean(account.cookie) },
|
|
33
|
+
snapshot: projectSnapshot(account.snapshot, account.quotaPerUnit),
|
|
34
|
+
history: account.history.map(item => projectSnapshot(item, account.quotaPerUnit)),
|
|
35
|
+
status: { ...account.status },
|
|
36
|
+
checkin: { date: account.checkin.date, checkedAt: account.checkin.checkedAt, rewardQuota: account.checkin.rewardQuota,
|
|
37
|
+
reward: amount(account.checkin.rewardQuota, account.quotaPerUnit), state: account.checkin.state, message: account.checkin.message } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function totalsFor(accounts) {
|
|
41
|
+
const groups = new Map();
|
|
42
|
+
for (const account of accounts) {
|
|
43
|
+
if (!groups.has(account.unit)) groups.set(account.unit, []);
|
|
44
|
+
groups.get(account.unit).push(account);
|
|
45
|
+
}
|
|
46
|
+
return Object.fromEntries([...groups].map(([unit, configured]) => {
|
|
47
|
+
const unique = new Map();
|
|
48
|
+
for (const account of configured) {
|
|
49
|
+
const previous = unique.get(identity(account));
|
|
50
|
+
// Prefer a current successful snapshot; aliases never multiply money.
|
|
51
|
+
if (!previous || !previous.snapshot && account.snapshot || previous.status.stale && !account.status.stale && account.snapshot || previous.status.stale === account.status.stale && (account.snapshot?.at ?? '') > (previous.snapshot?.at ?? '')) unique.set(identity(account), account);
|
|
52
|
+
}
|
|
53
|
+
const entries = [...unique.values()];
|
|
54
|
+
const available = entries.filter(a => a.snapshot);
|
|
55
|
+
const total = key => {
|
|
56
|
+
if (!available.length || available.some(a => a.snapshot[key] === null)) return null;
|
|
57
|
+
const value = available.reduce((sum, a) => sum + a.snapshot[key], 0);
|
|
58
|
+
return Number.isFinite(value) ? value : null;
|
|
59
|
+
};
|
|
60
|
+
const delta = key => available.length === entries.length && entries.every(a => !a.status.stale && a.snapshot[key] !== null) ? total(key) : null;
|
|
61
|
+
return [unit, { remaining: total('remaining'), used: total('used'), deltaBalance: delta('deltaBalance'), deltaUsed: delta('deltaUsed'),
|
|
62
|
+
accountCount: entries.length, configuredCount: configured.length, availableCount: available.length, staleCount: entries.filter(a => a.status.stale).length }];
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Independent accounts.json; all methods are async, and no network occurs on CRUD. */
|
|
67
|
+
export function createAccountManager({ path, fetchImpl, now = () => new Date(), uuid = randomUUID, client } = {}) {
|
|
68
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new AccountValidationError('账号文件必须使用绝对路径。');
|
|
69
|
+
path = resolve(path);
|
|
70
|
+
const key = process.platform === 'win32' ? path.toLowerCase() : path;
|
|
71
|
+
if (!sessions.has(key)) sessions.set(key, { tail: Promise.resolve(), refreshes: new Map(), checkins: new Map() });
|
|
72
|
+
const session = sessions.get(key);
|
|
73
|
+
const api = client ?? createNewApiClient({ fetchImpl, now });
|
|
74
|
+
const timestamp = () => new Date(now()).toISOString();
|
|
75
|
+
const locked = task => {
|
|
76
|
+
const result = session.tail.then(task, task);
|
|
77
|
+
session.tail = result.catch(() => {});
|
|
78
|
+
return result;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
async function read() {
|
|
82
|
+
try {
|
|
83
|
+
if ((await stat(path)).size > MAX_FILE_BYTES) throw new Error();
|
|
84
|
+
const state = JSON.parse(await readFile(path, 'utf8'));
|
|
85
|
+
if (state.version !== 1 || !Array.isArray(state.accounts) || state.accounts.length > MAX_ACCOUNTS) throw new Error();
|
|
86
|
+
const ids = new Set();
|
|
87
|
+
for (const account of state.accounts) {
|
|
88
|
+
if (!account || typeof account.id !== 'string' || !account.id || ids.has(account.id) || typeof account.revision !== 'string' || !Array.isArray(account.history) || account.history.length > MAX_HISTORY || !account.status || !account.checkin) throw new Error();
|
|
89
|
+
Object.assign(account, normalizeAccountConfig(account));
|
|
90
|
+
ids.add(account.id);
|
|
91
|
+
}
|
|
92
|
+
return state;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error?.code === 'ENOENT') return { version: 1, accounts: [] };
|
|
95
|
+
throw new AccountValidationError('账号文件无法读取。', 500);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function write(state) {
|
|
100
|
+
let temporary; let handle;
|
|
101
|
+
try {
|
|
102
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
103
|
+
temporary = `${path}.${randomUUID()}.tmp`;
|
|
104
|
+
handle = await open(temporary, 'wx', 0o600);
|
|
105
|
+
await handle.writeFile(JSON.stringify(state, null, 2), 'utf8');
|
|
106
|
+
await handle.sync();
|
|
107
|
+
await handle.close(); handle = null;
|
|
108
|
+
await rename(temporary, path); temporary = null;
|
|
109
|
+
} catch { throw new AccountValidationError('账号文件无法保存。', 500); }
|
|
110
|
+
finally { if (handle) await handle.close().catch(() => {}); if (temporary) await unlink(temporary).catch(() => {}); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function list() {
|
|
114
|
+
return locked(async () => { const accounts = (await read()).accounts.map(publicAccount); return { accounts, totals: totalsFor(accounts) }; });
|
|
115
|
+
}
|
|
116
|
+
async function get(id) { return locked(async () => publicAccount(own(await read(), id))); }
|
|
117
|
+
|
|
118
|
+
async function save(raw, clearSecrets = []) {
|
|
119
|
+
if (!Array.isArray(clearSecrets) || clearSecrets.some(name => !['accessToken', 'cookie'].includes(name))) throw new AccountValidationError('清除凭据的字段无效。');
|
|
120
|
+
const config = normalizeAccountConfig(raw);
|
|
121
|
+
return locked(async () => {
|
|
122
|
+
const state = await read();
|
|
123
|
+
const previous = raw.id === undefined ? null : own(state, raw.id);
|
|
124
|
+
if (!previous && state.accounts.length >= MAX_ACCOUNTS) throw new AccountValidationError('最多保存 100 个账号。', 409);
|
|
125
|
+
if (config.schedule.enabled && state.accounts.some(account => account !== previous
|
|
126
|
+
&& identity(account) === identity(config) && account.schedule.enabled)) {
|
|
127
|
+
throw new AccountValidationError('自动签到设置冲突。', 400, {
|
|
128
|
+
'schedule.enabled': '同一站点账号的别名只能设置一份自动签到,请先关闭另一份',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const unchangedIdentity = previous && identity(previous) === identity(config);
|
|
132
|
+
for (const secret of ['accessToken', 'cookie']) config[secret] = config[secret] || (!unchangedIdentity || clearSecrets.includes(secret) ? '' : previous?.[secret] ?? '');
|
|
133
|
+
const account = { ...config, id: previous?.id ?? uuid(), revision: randomUUID(),
|
|
134
|
+
snapshot: unchangedIdentity ? previous.snapshot : null,
|
|
135
|
+
history: unchangedIdentity ? previous.history : [],
|
|
136
|
+
status: unchangedIdentity ? previous.status : freshStatus(),
|
|
137
|
+
checkin: unchangedIdentity ? previous.checkin : freshCheckin() };
|
|
138
|
+
if (typeof account.id !== 'string' || !account.id || state.accounts.some(a => a.id === account.id && a !== previous)) throw new AccountValidationError('无法创建唯一账号 ID。', 409);
|
|
139
|
+
if (previous) state.accounts[state.accounts.indexOf(previous)] = account;
|
|
140
|
+
else state.accounts.push(account);
|
|
141
|
+
await write(state);
|
|
142
|
+
return publicAccount(account);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function remove(id) {
|
|
147
|
+
return locked(async () => { const state = await read(); own(state, id); state.accounts = state.accounts.filter(a => a.id !== id); await write(state); return { removed: true }; });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function performRefresh(captured) {
|
|
151
|
+
const at = timestamp();
|
|
152
|
+
let reading; let failure;
|
|
153
|
+
try {
|
|
154
|
+
reading = await api.balance({ ...captured });
|
|
155
|
+
if (!reading || !Number.isSafeInteger(reading.quota) || !Number.isSafeInteger(reading.usedQuota) || reading.usedQuota < 0 || !Number.isSafeInteger(reading.requestCount) || reading.requestCount < 0) throw new NewApiError('response');
|
|
156
|
+
} catch (error) { failure = safeNewApiMessage(error); }
|
|
157
|
+
return locked(async () => {
|
|
158
|
+
const state = await read(); const current = own(state, captured.id);
|
|
159
|
+
if (current.revision !== captured.revision || identity(current) !== identity(captured)) return publicAccount(current);
|
|
160
|
+
current.status = { lastAttemptAt: at, stale: Boolean(failure), error: failure ?? null };
|
|
161
|
+
if (!failure) {
|
|
162
|
+
const prior = current.snapshot;
|
|
163
|
+
const usedReset = Boolean(prior && reading.usedQuota < prior.usedQuota);
|
|
164
|
+
const requestReset = Boolean(prior && reading.requestCount < prior.requestCount);
|
|
165
|
+
current.snapshot = { at: reading.at && Number.isFinite(Date.parse(reading.at)) ? new Date(reading.at).toISOString() : at,
|
|
166
|
+
quota: reading.quota, usedQuota: reading.usedQuota, requestCount: reading.requestCount,
|
|
167
|
+
deltaQuota: prior ? reading.quota - prior.quota : null,
|
|
168
|
+
deltaUsedQuota: prior && !usedReset ? reading.usedQuota - prior.usedQuota : null,
|
|
169
|
+
deltaRequests: prior && !requestReset ? reading.requestCount - prior.requestCount : null,
|
|
170
|
+
reset: usedReset || requestReset };
|
|
171
|
+
current.history = [...current.history, current.snapshot].slice(-MAX_HISTORY);
|
|
172
|
+
}
|
|
173
|
+
await write(state);
|
|
174
|
+
return publicAccount(current);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function refresh(id) {
|
|
179
|
+
const captured = await locked(async () => structuredClone(own(await read(), id)));
|
|
180
|
+
if (!captured.enabled) return publicAccount(captured);
|
|
181
|
+
const requestKey = `${id}:${captured.revision}`;
|
|
182
|
+
if (!session.refreshes.has(requestKey)) {
|
|
183
|
+
const pending = performRefresh(captured).finally(() => { if (session.refreshes.get(requestKey) === pending) session.refreshes.delete(requestKey); });
|
|
184
|
+
session.refreshes.set(requestKey, pending);
|
|
185
|
+
}
|
|
186
|
+
return session.refreshes.get(requestKey);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function checkinResult(result, at) {
|
|
190
|
+
const states = ['success', 'already', 'disabled', 'requires_verification', 'unknown', 'error'];
|
|
191
|
+
const state = result?.state === 'already_checked' ? 'already' : states.includes(result?.state) ? result.state : 'error';
|
|
192
|
+
const validDate = /^\d{4}-\d{2}-\d{2}$/u.test(result?.date ?? '') && Number.isFinite(Date.parse(result.date)) && day(result.date) === result.date;
|
|
193
|
+
const messages = { success: '签到成功。', already: '今日已签到。', disabled: '站点未启用签到。', requires_verification: '请前往站点完成验证码后签到。', unknown: '签到结果未确认,请在站点确认记录;不会自动重试。', error: '签到未完成,请前往站点检查。' };
|
|
194
|
+
return { date: successful({ state }) ? validDate ? result.date : day(at) : null,
|
|
195
|
+
checkedAt: successful({ state }) ? at : null,
|
|
196
|
+
rewardQuota: successful({ state }) && Number.isSafeInteger(result.rewardQuota) && result.rewardQuota >= 0 ? result.rewardQuota : null,
|
|
197
|
+
state, message: messages[state],
|
|
198
|
+
// Returned site dates are authoritative; UTC is the explicit fallback when absent.
|
|
199
|
+
dedupeDay: successful({ state }) ? day(at) : null };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function performCheckin(captured, aliases, options) {
|
|
203
|
+
const at = timestamp(); let result;
|
|
204
|
+
try { result = checkinResult(await api.checkin({ ...captured }, options), at); }
|
|
205
|
+
catch (error) {
|
|
206
|
+
const code = error instanceof NewApiError ? error.code : null;
|
|
207
|
+
const state = code === 'turnstile' ? 'requires_verification' : code === 'ambiguous' ? 'unknown' : 'error';
|
|
208
|
+
result = { ...checkinResult({ state }, at), message: safeNewApiMessage(error) };
|
|
209
|
+
}
|
|
210
|
+
const updated = await locked(async () => {
|
|
211
|
+
const state = await read(); const initiator = state.accounts.find(a => a.id === captured.id);
|
|
212
|
+
if (!initiator || initiator.revision !== captured.revision) return false;
|
|
213
|
+
for (const alias of aliases) {
|
|
214
|
+
const current = state.accounts.find(a => a.id === alias.id);
|
|
215
|
+
if (current && current.revision === alias.revision && identity(current) === identity(captured)) current.checkin = { ...result };
|
|
216
|
+
}
|
|
217
|
+
await write(state); return true;
|
|
218
|
+
});
|
|
219
|
+
if (updated && successful(result)) {
|
|
220
|
+
// A later refresh failure only marks the balance stale, never changes checkin success.
|
|
221
|
+
for (const alias of aliases) {
|
|
222
|
+
try {
|
|
223
|
+
const current = await locked(async () => own(await read(), alias.id));
|
|
224
|
+
if (current.revision === alias.revision) await refresh(alias.id);
|
|
225
|
+
} catch { /* A removed alias or a failed refresh cannot undo a confirmed checkin. */ }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function checkin(id, { turnstileToken, expectedRevision } = {}) {
|
|
231
|
+
const selection = await locked(async () => {
|
|
232
|
+
const state = await read(); const captured = own(state, id);
|
|
233
|
+
if (expectedRevision !== undefined) {
|
|
234
|
+
if (expectedRevision !== captured.revision) throw new AccountValidationError('账号配置已更改,本次自动签到已取消。', 409);
|
|
235
|
+
if (!captured.schedule.enabled || !scheduleOccurrence(captured.schedule, new Date(now()))) {
|
|
236
|
+
throw new AccountValidationError('自动签到已关闭或已错过指定时间,本次未执行。', 409);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!captured.enabled || !captured.checkinEnabled) return { skip: publicAccount(captured) };
|
|
240
|
+
// Check the current id/revision before joining; identity, not revision, deduplicates POSTs.
|
|
241
|
+
const requestKey = identity(captured);
|
|
242
|
+
if (session.checkins.has(requestKey)) return { pending: session.checkins.get(requestKey) };
|
|
243
|
+
const sameIdentity = state.accounts.filter(a => identity(a) === identity(captured));
|
|
244
|
+
const known = sameIdentity.find(a => successful(a.checkin) && (a.checkin.dedupeDay ?? a.checkin.date) === day(timestamp()));
|
|
245
|
+
if (known && !api.authoritativeCheckinDay) {
|
|
246
|
+
if (captured !== known) { captured.checkin = { ...known.checkin }; await write(state); }
|
|
247
|
+
return { skip: publicAccount(captured) };
|
|
248
|
+
}
|
|
249
|
+
const aliases = sameIdentity.filter(a => a.enabled && a.checkinEnabled).map(a => ({ id: a.id, revision: a.revision }));
|
|
250
|
+
const pending = performCheckin(structuredClone(captured), aliases, { turnstileToken }).finally(() => {
|
|
251
|
+
if (session.checkins.get(requestKey) === pending) session.checkins.delete(requestKey);
|
|
252
|
+
});
|
|
253
|
+
session.checkins.set(requestKey, pending);
|
|
254
|
+
// Do not await network while holding the file transaction queue.
|
|
255
|
+
return { pending };
|
|
256
|
+
});
|
|
257
|
+
if (selection.skip) return selection.skip;
|
|
258
|
+
await selection.pending;
|
|
259
|
+
return get(id);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function refreshAll() {
|
|
263
|
+
const { accounts } = await list();
|
|
264
|
+
for (const account of accounts.filter(a => a.enabled)) {
|
|
265
|
+
try { await refresh(account.id); } catch { /* Isolate deletion and individual account failures. */ }
|
|
266
|
+
}
|
|
267
|
+
return list();
|
|
268
|
+
}
|
|
269
|
+
async function checkinAll() {
|
|
270
|
+
const { accounts } = await list();
|
|
271
|
+
const attempted = new Set();
|
|
272
|
+
for (const account of accounts.filter(a => a.enabled && a.checkinEnabled)) {
|
|
273
|
+
const key = identity(account);
|
|
274
|
+
if (attempted.has(key)) continue;
|
|
275
|
+
attempted.add(key);
|
|
276
|
+
try { await checkin(account.id); } catch { /* Continue the explicitly requested batch. */ }
|
|
277
|
+
}
|
|
278
|
+
return list();
|
|
279
|
+
}
|
|
280
|
+
return { list, get, save, remove, refresh, checkin, refreshAll, checkinAll };
|
|
281
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createDefaultProfile, normalizeProfile, targetOf } from './profile.mjs';
|
|
3
|
+
import { buildHeaders } from './headers.mjs';
|
|
4
|
+
|
|
5
|
+
export const AUTH_MARKER = 'magpie-claude-profile-configured';
|
|
6
|
+
const text = value => typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
7
|
+
const sameTarget = (left, right) => targetOf(left) !== null && targetOf(left) === targetOf(right);
|
|
8
|
+
|
|
9
|
+
/** defaults contains explicit global options only; credentials always come from the account or same-target saved profile. */
|
|
10
|
+
export function connectionProfile(saved, auth, provider = {}, defaults) {
|
|
11
|
+
const key = auth?.type === 'api' && text(auth.key) !== AUTH_MARKER ? text(auth.key) : undefined;
|
|
12
|
+
const endpoint = text(auth?.metadata?.baseURL) ?? text(auth?.metadata?.endpoint) ?? text(auth?.endpoint) ??
|
|
13
|
+
text(provider.options?.baseURL) ?? text(provider.api) ?? text(provider.api?.url) ?? text(saved?.upstream?.baseUrl) ?? text(defaults?.upstream?.baseUrl);
|
|
14
|
+
const candidate = key ? auth?.metadata?.claudeProfile : undefined;
|
|
15
|
+
const scoped = candidate && sameTarget(endpoint, candidate.upstream?.baseUrl) ? candidate : undefined;
|
|
16
|
+
// A native key change is a separate connection, even at the same host and path.
|
|
17
|
+
const inherit = saved && sameTarget(endpoint, saved.upstream?.baseUrl) && (!key || saved.upstream?.apiKey === key);
|
|
18
|
+
const source = scoped ?? (inherit ? saved : undefined);
|
|
19
|
+
const base = createDefaultProfile();
|
|
20
|
+
const global = defaults ? normalizeProfile(defaults, { allowBlank: true }) : undefined;
|
|
21
|
+
const profile = structuredClone(source ?? global ?? base);
|
|
22
|
+
if (source && global) {
|
|
23
|
+
profile.client = { ...global.client, ...source.client };
|
|
24
|
+
profile.headers = { ...global.headers, ...source.headers, extra: [...global.headers.extra, ...(source.headers?.extra ?? [])] };
|
|
25
|
+
profile.parameters = { ...global.parameters, ...source.parameters };
|
|
26
|
+
profile.models = source.models ?? global.models;
|
|
27
|
+
}
|
|
28
|
+
profile.upstream = { ...base.upstream, ...(source?.upstream ?? {}), baseUrl: endpoint ?? '' };
|
|
29
|
+
if (key) {
|
|
30
|
+
profile.upstream.apiKey = key;
|
|
31
|
+
// Only account-scoped modes are explicit for native auth. Native default is x-api-key.
|
|
32
|
+
profile.upstream.authorizationMode = scoped?.upstream?.authorizationMode ?? 'api-key';
|
|
33
|
+
profile.upstream.authorizationValue = scoped?.upstream?.authorizationValue ?? '';
|
|
34
|
+
}
|
|
35
|
+
return normalizeProfile(profile);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function modelRecords(profile, ids = profile.models, existing = {}) {
|
|
39
|
+
const npm = '@ai-sdk/anthropic';
|
|
40
|
+
return Object.fromEntries([...new Set(ids)].map(id => [id, {
|
|
41
|
+
...(existing[id] ?? {}), name: existing[id]?.name ?? id,
|
|
42
|
+
api: { id: existing[id]?.api?.id ?? existing[id]?.id ?? id, url: profile.upstream.baseUrl, npm, ...(existing[id]?.api ?? {}) },
|
|
43
|
+
provider: { npm, api: profile.upstream.baseUrl, ...(existing[id]?.provider ?? {}) },
|
|
44
|
+
}]));
|
|
45
|
+
}
|
|
46
|
+
export class ModelDiscoveryError extends Error {
|
|
47
|
+
constructor(code, httpStatus) {
|
|
48
|
+
super(code === 'unauthorized' ? '模型列表拒绝了当前 API key,请重新登录' : '无法自动获取模型,请检查地址和 API key;也可在可选高级设置中填写模型 ID');
|
|
49
|
+
this.name = 'ModelDiscoveryError'; this.code = code;
|
|
50
|
+
if (httpStatus !== undefined) this.httpStatus = httpStatus;
|
|
51
|
+
if (code === 'unauthorized') this.signIn = 'expired';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function responseErrorCode(status) {
|
|
55
|
+
if (status >= 300 && status < 400) return 'redirect';
|
|
56
|
+
if (status === 401) return 'unauthorized';
|
|
57
|
+
if (status === 403) return 'forbidden';
|
|
58
|
+
if (status === 404 || status === 405) return 'unsupported-list';
|
|
59
|
+
if (status === 429) return 'rate-limited';
|
|
60
|
+
if (status >= 500 && status < 600) return 'upstream-error';
|
|
61
|
+
return status >= 200 && status < 300 ? 'invalid-response' : 'http-error';
|
|
62
|
+
}
|
|
63
|
+
async function requestModelRecords(profile, { fetchImpl, timeoutMs, requireStatus200 }) {
|
|
64
|
+
const url = `${profile.upstream.baseUrl.replace(/\/+$/, '')}/models`;
|
|
65
|
+
const headers = buildHeaders({ profile, stream: false });
|
|
66
|
+
for (const name of ['x-claude-code-session-id', 'session-id', 'x-client-request-id', 'x-request-id', 'request-id']) headers.delete(name);
|
|
67
|
+
headers.set('accept', 'application/json');
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
let timeout, response, httpStatus, reader, cancelled = false;
|
|
70
|
+
const cancel = () => {
|
|
71
|
+
const body = reader ?? response?.body;
|
|
72
|
+
if (!body || cancelled) return;
|
|
73
|
+
cancelled = true;
|
|
74
|
+
try { void Promise.resolve(body.cancel()).catch(() => {}); } catch {}
|
|
75
|
+
};
|
|
76
|
+
const release = () => { try { reader?.releaseLock(); } catch {} reader = undefined; };
|
|
77
|
+
const deadline = new Promise((_, reject) => {
|
|
78
|
+
timeout = setTimeout(() => { reject(new ModelDiscoveryError('timeout', httpStatus)); controller.abort(); cancel(); release(); }, timeoutMs);
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
return await Promise.race([deadline, (async () => {
|
|
82
|
+
try { response = await fetchImpl(url, { method: 'GET', headers, redirect: 'manual', signal: controller.signal }); }
|
|
83
|
+
catch { throw new ModelDiscoveryError('network'); }
|
|
84
|
+
if (controller.signal.aborted) { cancel(); throw new ModelDiscoveryError('timeout'); }
|
|
85
|
+
if (Number.isInteger(response.status) && response.status >= 100 && response.status <= 599) httpStatus = response.status;
|
|
86
|
+
if (response.redirected || (response.url && response.url !== url)) throw new ModelDiscoveryError('redirect', httpStatus);
|
|
87
|
+
if (!response.ok || (requireStatus200 && response.status !== 200)) throw new ModelDiscoveryError(responseErrorCode(response.status), httpStatus);
|
|
88
|
+
if (Number(response.headers.get('content-length')) > 1024 * 1024) throw new ModelDiscoveryError('too-large', httpStatus);
|
|
89
|
+
reader = response.body?.getReader();
|
|
90
|
+
if (!reader) throw new ModelDiscoveryError('invalid-shape', httpStatus);
|
|
91
|
+
const chunks = []; let size = 0;
|
|
92
|
+
while (true) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (controller.signal.aborted) throw new ModelDiscoveryError('timeout', httpStatus);
|
|
95
|
+
if (done) break;
|
|
96
|
+
size += value.byteLength;
|
|
97
|
+
if (size > 1024 * 1024) throw new ModelDiscoveryError('too-large', httpStatus);
|
|
98
|
+
chunks.push(Buffer.from(value));
|
|
99
|
+
}
|
|
100
|
+
let data;
|
|
101
|
+
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
102
|
+
catch { throw new ModelDiscoveryError('invalid-json', httpStatus); }
|
|
103
|
+
if (!Array.isArray(data?.data)) throw new ModelDiscoveryError('invalid-shape', httpStatus);
|
|
104
|
+
const entries = data.data.filter(item => typeof item?.id === 'string' && item.id.trim() && !/[\u0000-\u001f\u007f]/.test(item.id)).slice(0, 2000);
|
|
105
|
+
if (!entries.length) throw new ModelDiscoveryError('empty-model-list', httpStatus);
|
|
106
|
+
const existing = Object.fromEntries(entries.map(item => {
|
|
107
|
+
const model = { name: text(item.display_name) ?? text(item.name) ?? item.id };
|
|
108
|
+
const context = item.limit?.context ?? item.context_window ?? item.context_length ?? item.max_context_tokens;
|
|
109
|
+
const output = item.limit?.output ?? item.max_output_tokens;
|
|
110
|
+
const limit = {};
|
|
111
|
+
if (Number.isSafeInteger(context) && context > 0) limit.context = context;
|
|
112
|
+
if (Number.isSafeInteger(output) && output > 0) limit.output = output;
|
|
113
|
+
if (Object.keys(limit).length) model.limit = limit;
|
|
114
|
+
return [item.id, model];
|
|
115
|
+
}));
|
|
116
|
+
return modelRecords(profile, entries.map(item => item.id), existing);
|
|
117
|
+
})()]);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
cancel();
|
|
120
|
+
if (error instanceof ModelDiscoveryError) throw error;
|
|
121
|
+
throw new ModelDiscoveryError('network', httpStatus);
|
|
122
|
+
} finally { clearTimeout(timeout); release(); }
|
|
123
|
+
}
|
|
124
|
+
export function createModelDiscovery({ fetchImpl = globalThis.fetch, now = Date.now, ttlMs = 60000, timeoutMs = 15000 } = {}) {
|
|
125
|
+
const cache = new Map(); const pending = new Map();
|
|
126
|
+
return async function discover(raw, { force = false, ignoreConfigured = false, requireStatus200 = false } = {}) {
|
|
127
|
+
const profile = normalizeProfile(raw);
|
|
128
|
+
if (!ignoreConfigured && profile.models.length) return modelRecords(profile);
|
|
129
|
+
if (force) return requestModelRecords(profile, { fetchImpl, timeoutMs, requireStatus200 });
|
|
130
|
+
const key = createHash('sha256').update(JSON.stringify(profile)).digest('hex');
|
|
131
|
+
const hit = cache.get(key);
|
|
132
|
+
if (hit && now() - hit.at < ttlMs) return structuredClone(hit.models);
|
|
133
|
+
if (pending.has(key)) return structuredClone(await pending.get(key));
|
|
134
|
+
if (pending.size >= 32) throw new ModelDiscoveryError('rate-limited');
|
|
135
|
+
const task = (async () => {
|
|
136
|
+
const models = await requestModelRecords(profile, { fetchImpl, timeoutMs, requireStatus200 });
|
|
137
|
+
if (cache.size >= 32) cache.delete(cache.keys().next().value);
|
|
138
|
+
cache.set(key, { at: now(), models }); return models;
|
|
139
|
+
})();
|
|
140
|
+
pending.set(key, task);
|
|
141
|
+
try { return structuredClone(await task); }
|
|
142
|
+
finally { pending.delete(key); }
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createModelDiscovery, ModelDiscoveryError } from './connection.mjs';
|
|
2
|
+
import { normalizeProfile, ProfileValidationError } from './profile.mjs';
|
|
3
|
+
const outcomes = new Map([
|
|
4
|
+
['unauthorized', { title: '模型列表请求未获授权', message: '请核对 API 凭据后重新检查。', nextAction: 'edit-credentials' }],
|
|
5
|
+
['forbidden', { title: '模型列表访问被拒绝', message: '服务拒绝了模型列表请求,请核对账户访问权限或联系服务方确认访问限制。', nextAction: 'edit-credentials' }],
|
|
6
|
+
['unsupported-list', { title: '模型列表接口不可用', message: '服务未提供可用的模型列表接口。可手动填写模型 ID;生成能力尚未测试。', nextAction: 'enter-models' }],
|
|
7
|
+
['empty-model-list', { title: '未获得可用的模型 ID', message: '模型列表未包含可用的模型 ID。可手动填写模型 ID;生成能力尚未测试。', nextAction: 'enter-models' }],
|
|
8
|
+
['rate-limited', { title: '模型列表请求受到限流', message: '请稍后重新检查。', nextAction: 'retry' }],
|
|
9
|
+
['redirect', { title: '模型列表请求发生重定向', message: '检查未接受重定向,请核对并填写服务的直接地址。', nextAction: 'edit-address' }],
|
|
10
|
+
['upstream-error', { title: '上游服务暂时异常', message: '服务未能完成模型列表请求,请稍后重试。', nextAction: 'retry' }],
|
|
11
|
+
['invalid-response', { title: '模型列表响应无效', message: '模型列表响应无法识别或超过大小限制,请核对服务地址。', nextAction: 'edit-address' }],
|
|
12
|
+
['timeout', { title: '模型列表检查超时', message: '未能在限定时间内完整获取模型列表,请稍后重试。', nextAction: 'retry' }],
|
|
13
|
+
['network', { title: '模型列表网络请求失败', message: '未能完整获取模型列表,请检查网络连接后重试。', nextAction: 'retry' }],
|
|
14
|
+
['http-error', { title: '模型列表请求失败', message: '服务未接受模型列表请求,请确认服务状态后重试。', nextAction: 'retry' }],
|
|
15
|
+
]);
|
|
16
|
+
/** Checks only GET /models; never sends generation requests. */
|
|
17
|
+
export function createConnectionDiagnostics({ fetchImpl = globalThis.fetch, timeoutMs = 15000, now = Date.now } = {}) {
|
|
18
|
+
const discover = createModelDiscovery({ fetchImpl, timeoutMs, now });
|
|
19
|
+
return async function check(raw) {
|
|
20
|
+
const profile = normalizeProfile(raw);
|
|
21
|
+
const credentials = [profile.upstream.apiKey, profile.upstream.authorizationValue].flatMap(value => [value, value.replace(/^\S+\s+/, '')]).filter(Boolean);
|
|
22
|
+
try {
|
|
23
|
+
const models = await discover(profile, { force: true, ignoreConfigured: true, requireStatus200: true });
|
|
24
|
+
const modelIds = Object.keys(models);
|
|
25
|
+
if (modelIds.some(id => credentials.some(secret => id.includes(secret)))) throw new ModelDiscoveryError('invalid-response', 200);
|
|
26
|
+
return { state: 'models-found', code: 'models-found', title: '已获取模型列表', message: '已获取模型列表;尚未测试生成或回复能力。', nextAction: 'save', modelIds,
|
|
27
|
+
checkedAt: new Date(now()).toISOString(), checks: { modelList: 'passed', generation: 'not-tested' }, httpStatus: 200 };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof ProfileValidationError) throw error;
|
|
30
|
+
const failure = error instanceof ModelDiscoveryError ? error : undefined;
|
|
31
|
+
const code = ['invalid-json', 'invalid-shape', 'too-large'].includes(failure?.code) ? 'invalid-response' : outcomes.has(failure?.code) ? failure.code : 'network';
|
|
32
|
+
const unavailable = code === 'unsupported-list' || code === 'empty-model-list';
|
|
33
|
+
return { state: unavailable ? 'models-unavailable' : 'failed', code, ...outcomes.get(code), modelIds: [], checkedAt: new Date(now()).toISOString(),
|
|
34
|
+
checks: { modelList: unavailable ? 'unavailable' : 'failed', generation: 'not-tested' }, ...(failure?.httpStatus === undefined ? {} : { httpStatus: failure.httpStatus }) };
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/headers.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CLIENT_PRESETS, HEADER_LIMIT_BYTES, normalizeProfile, isProtectedHeader, isSafeHeaderText } from './profile.mjs';
|
|
2
|
+
export { HEADER_LIMIT_BYTES };
|
|
3
|
+
export class HeaderBuildError extends Error {
|
|
4
|
+
constructor(message, field) { super(message); this.name = 'HeaderBuildError'; this.code = 'HEADER_BUILD_ERROR'; if (field !== undefined) this.field = field; }
|
|
5
|
+
}
|
|
6
|
+
function render(value, context, field) {
|
|
7
|
+
let missing = false;
|
|
8
|
+
const rendered = value.replace(/\{\{([^{}]+)\}\}/g, (_, name) => {
|
|
9
|
+
const [group, key] = name.split('.');
|
|
10
|
+
const item = context[name] ?? context[group]?.[key];
|
|
11
|
+
if ((typeof item !== 'string' || !item.trim()) && !(typeof item === 'number' && Number.isFinite(item)) && typeof item !== 'boolean') { missing = true; return ''; }
|
|
12
|
+
return String(item);
|
|
13
|
+
});
|
|
14
|
+
if (missing) return undefined;
|
|
15
|
+
if (!isSafeHeaderText(rendered) || rendered.length > HEADER_LIMIT_BYTES) throw new HeaderBuildError('请求头值含有非法字符或超过大小限制', field);
|
|
16
|
+
return rendered;
|
|
17
|
+
}
|
|
18
|
+
export function buildHeaders({ profile, baseHeaders, context = {}, stream } = {}) {
|
|
19
|
+
let p;
|
|
20
|
+
try { p = normalizeProfile(profile, { allowBlank: true }); }
|
|
21
|
+
catch { throw new HeaderBuildError('profile 配置无效'); }
|
|
22
|
+
let headers;
|
|
23
|
+
try { headers = new Headers(baseHeaders ?? undefined); }
|
|
24
|
+
catch { throw new HeaderBuildError('baseHeaders 不是有效的请求头集合'); }
|
|
25
|
+
for (const name of [...headers.keys()]) if (isProtectedHeader(name)) headers.delete(name);
|
|
26
|
+
const callerAccept = headers.get('accept');
|
|
27
|
+
const preset = CLIENT_PRESETS[p.client.preset];
|
|
28
|
+
const version = p.client.version.trim() || preset.version;
|
|
29
|
+
const variables = { ...context, 'client.version': version };
|
|
30
|
+
// All fourteen selected preset values win over caller headers. Explicit settings follow.
|
|
31
|
+
for (const [name, value] of Object.entries(preset.headers)) headers.set(name, value);
|
|
32
|
+
if (preset.userAgent && p.client.version.trim()) headers.set('user-agent', preset.userAgent.replace(`/${preset.version}`, `/${version}`));
|
|
33
|
+
const set = (name, value, field = name) => {
|
|
34
|
+
const text = render(value, variables, field);
|
|
35
|
+
if (text !== undefined && text.trim()) headers.set(name, text);
|
|
36
|
+
};
|
|
37
|
+
if (p.headers.acceptMode === 'json') headers.set('accept', 'application/json');
|
|
38
|
+
else if (p.headers.acceptMode === 'sse') headers.set('accept', 'text/event-stream');
|
|
39
|
+
else if (p.headers.acceptMode === 'auto') headers.set('accept', callerAccept ?? (stream === true ? 'text/event-stream' : 'application/json'));
|
|
40
|
+
else set('accept', p.headers.acceptValue, 'headers.acceptValue');
|
|
41
|
+
if (!headers.has('content-type')) headers.set('content-type', 'application/json');
|
|
42
|
+
for (const [field, name] of Object.entries({ userAgent: 'user-agent', contentType: 'content-type', anthropicVersion: 'anthropic-version', anthropicBeta: 'anthropic-beta', sessionId: 'x-claude-code-session-id', clientRequestId: 'x-client-request-id' })) {
|
|
43
|
+
if (field === 'clientRequestId' && p.headers[field] === '{{request.id}}' && headers.has(name)) continue;
|
|
44
|
+
if (field === 'sessionId' && p.headers[field] === '{{session.id}}' && headers.has(name)) continue;
|
|
45
|
+
if (p.headers[field]) set(name, p.headers[field], `headers.${field}`);
|
|
46
|
+
}
|
|
47
|
+
for (const [index, item] of p.headers.extra.entries()) {
|
|
48
|
+
if (!item.enabled) continue;
|
|
49
|
+
if (item.value === null) headers.delete(item.name);
|
|
50
|
+
else set(item.name, item.value, `headers.extra[${index}].value`);
|
|
51
|
+
}
|
|
52
|
+
const upstream = p.upstream;
|
|
53
|
+
if (upstream.authorizationMode === 'api-key' && upstream.apiKey.trim()) headers.set('x-api-key', upstream.apiKey);
|
|
54
|
+
else if (upstream.authorizationMode === 'bearer' && upstream.apiKey.trim()) headers.set('authorization', `Bearer ${upstream.apiKey}`);
|
|
55
|
+
else if (upstream.authorizationMode === 'custom' && upstream.authorizationValue.trim()) headers.set('authorization', upstream.authorizationValue);
|
|
56
|
+
let size = 0;
|
|
57
|
+
for (const [name, value] of headers) size += Buffer.byteLength(`${name}: ${value}\r\n`);
|
|
58
|
+
if (size > HEADER_LIMIT_BYTES) throw new HeaderBuildError('请求头总大小超过限制');
|
|
59
|
+
return headers;
|
|
60
|
+
}
|