opencode-claude-code-ua 0.3.6 → 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/README.md +94 -90
- package/index.mjs +2 -6
- package/lib/ua-protocol.mjs +8 -0
- package/package.json +1 -1
- package/setup.mjs +1 -1
- package/src/account-usage.mjs +33 -0
- package/src/accounts.mjs +96 -23
- package/src/key-usage.mjs +73 -0
- package/src/newapi.mjs +106 -13
- package/src/plugin.mjs +4 -34
- package/src/scheduler.mjs +2 -2
- package/src/setup-server.mjs +16 -3
- package/src/ua-plugin.mjs +57 -14
- package/src/ua-runtime.mjs +1 -1
- package/src/ua-service.mjs +38 -6
- package/src/ua-store.mjs +4 -1
- package/src/ua-usage.mjs +64 -0
- package/ui/accounts.css +1 -1
- package/ui/accounts.html +21 -11
- package/ui/accounts.js +230 -26
- package/ui/pro.css +1 -0
- package/ui/pro.html +10 -11
- package/ui/pro.js +82 -12
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { siteRoot } from '../lib/ua-protocol.mjs'
|
|
2
|
+
import { boundedResponse, credential } from './ua-runtime.mjs'
|
|
3
|
+
|
|
4
|
+
const localErrors = new WeakSet()
|
|
5
|
+
const fail = (message, code = 'response') => { const error = Object.assign(new Error(message), { code }); localErrors.add(error); return error }
|
|
6
|
+
const money = value => '$' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })
|
|
7
|
+
const quota = value => Number.isSafeInteger(value)
|
|
8
|
+
const unavailable = message => ({ signIn: 'kept', error: message, windows: [{ name: 'Key 用量', aside: true, display: `${message};需签到请绑定站点账号,模型 key 不替代登录凭据` }] })
|
|
9
|
+
|
|
10
|
+
export function createKeyUsage({ fetchImpl = globalThis.fetch, readAuth } = {}) {
|
|
11
|
+
async function request(url, headers) {
|
|
12
|
+
const controller = new AbortController()
|
|
13
|
+
let timer
|
|
14
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail('Key 用量查询超时,请稍后手动重试', 'timeout')) }, 15000) })
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([deadline, (async () => {
|
|
17
|
+
const response = await fetchImpl(url, { method: 'GET', headers, redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer', signal: controller.signal })
|
|
18
|
+
const stop = (message, code) => { void response.body?.cancel().catch(() => {}); throw fail(message, code) }
|
|
19
|
+
if (response.redirected || response.status >= 300 && response.status < 400 || response.url && new URL(response.url).origin !== new URL(url).origin) stop('站点重定向已阻止,未向其他地址发送凭据', 'redirect')
|
|
20
|
+
if (response.status === 401 || response.status === 403) stop('Key 用量查询未获授权,请核对 Key 或绑定站点账号', 'auth')
|
|
21
|
+
if (response.status === 404 || response.status === 405) stop('站点未提供此 Key 用量接口', 'unsupported')
|
|
22
|
+
if (!response.ok) stop('Key 用量查询失败,请检查站点服务', 'http')
|
|
23
|
+
const body = await boundedResponse(response, { limit: 1024 * 1024, signal: controller.signal })
|
|
24
|
+
let result
|
|
25
|
+
try { result = JSON.parse(body) } catch { throw fail('站点返回的 Key 用量数据无效') }
|
|
26
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || result.success === false || result.code === false) throw fail('站点未确认 Key 用量查询成功')
|
|
27
|
+
return result
|
|
28
|
+
})()])
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (localErrors.has(error)) throw error
|
|
31
|
+
throw fail('Key 用量查询未完成,请检查站点地址与网络', 'network')
|
|
32
|
+
} finally { clearTimeout(timer) }
|
|
33
|
+
}
|
|
34
|
+
return async connection => {
|
|
35
|
+
try {
|
|
36
|
+
const root = siteRoot(connection.baseURL)
|
|
37
|
+
const auth = await credential(connection, readAuth)
|
|
38
|
+
const key = ['api-key', 'bearer'].includes(auth.mode) ? auth.key : auth.mode === 'custom' ? /^Bearer\s+([^\s]+)$/i.exec(auth.value ?? '')?.[1] : null
|
|
39
|
+
if (!key) return unavailable('此认证方式不提供模型 Key 的只读用量查询')
|
|
40
|
+
const headers = { accept: 'application/json', authorization: `Bearer ${key}` }
|
|
41
|
+
let result
|
|
42
|
+
try { result = await request(root + '/api/usage/token/', headers) }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== 'unsupported') throw error
|
|
45
|
+
const billing = await request(root + '/v1/dashboard/billing/usage', headers)
|
|
46
|
+
const spent = billing.total_usage ?? billing.data?.total_usage
|
|
47
|
+
if (typeof spent !== 'number' || !Number.isFinite(spent) || spent < 0) throw fail('站点未返回可确认的 Key 累计消耗')
|
|
48
|
+
return { plan: 'Key 累计消耗', user: connection.name, signIn: 'kept', windows: [{ name: 'Key 用量', aside: true, amount: spent / 100, unit: 'usd', display: `累计消耗 ${money(spent / 100)};余额未知。签到需绑定站点账号` }] }
|
|
49
|
+
}
|
|
50
|
+
const data = result.data
|
|
51
|
+
if (result.code !== true || data?.object !== 'token_usage' || !quota(data.total_granted) || data.total_granted < 0 || !quota(data.total_used) || data.total_used < 0 || !quota(data.total_available) || typeof data.unlimited_quota !== 'boolean' || !quota(data.expires_at) || data.expires_at < 0) throw fail('站点返回的 Key 额度字段无效')
|
|
52
|
+
let scale = 500000, estimated = true
|
|
53
|
+
try {
|
|
54
|
+
const status = await request(root + '/api/status', { accept: 'application/json' })
|
|
55
|
+
if (status.success === true && typeof status.data?.quota_per_unit === 'number' && Number.isFinite(status.data.quota_per_unit) && status.data.quota_per_unit > 0) { scale = status.data.quota_per_unit; estimated = false }
|
|
56
|
+
} catch { /* The token reading remains valid; label the default conversion as estimated. */ }
|
|
57
|
+
const available = data.total_available / scale, used = data.total_used / scale, total = data.total_granted / scale
|
|
58
|
+
if (![available, used, total].every(Number.isFinite)) throw fail('站点额度换算超出允许范围')
|
|
59
|
+
const user = data.user_quota
|
|
60
|
+
const userAvailable = quota(user?.total_available) && quota(user?.total_used) && user.total_used >= 0 ? user.total_available / scale : null
|
|
61
|
+
const suffix = estimated ? '(估算)' : ''
|
|
62
|
+
const balance = Number.isFinite(userAvailable) ? money(userAvailable) + suffix : data.unlimited_quota ? 'Key 不限额(账号余额未知)' : money(available) + suffix
|
|
63
|
+
let until
|
|
64
|
+
if (data.expires_at > 0) { const date = new Date(data.expires_at * 1000); if (!Number.isFinite(date.getTime())) throw fail('Key 过期时间无效'); until = date.toISOString() }
|
|
65
|
+
return { plan: Number.isFinite(userAvailable) ? 'New API 账号余额(Key 已授权查询)' : 'New API Key 额度', user: connection.name, signIn: 'kept', balance,
|
|
66
|
+
...(until ? { until } : {}),
|
|
67
|
+
windows: [{ name: 'Key 额度', aside: true, amount: used, unit: 'usd',
|
|
68
|
+
...(!data.unlimited_quota ? { limit: total, used: total > 0 ? Math.min(100, Math.max(0, used / total * 100)) : 0 } : {}),
|
|
69
|
+
display: `${data.unlimited_quota ? 'Key 不限额' : `Key 剩余 ${money(available)}`};累计消耗 ${money(used)}${Number.isFinite(userAvailable) ? `;账号余额 ${money(userAvailable)}` : ';这是 Key 额度,不等于账号总余额'}${estimated ? ';额度倍率未确认,按 500000 quota/USD 估算' : ''};签到需绑定站点账号` }],
|
|
70
|
+
}
|
|
71
|
+
} catch (error) { return unavailable(localErrors.has(error) ? error.message : 'Key 用量读取未完成,请核对连接凭据与地址') }
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/newapi.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const ERRORS = Object.freeze({
|
|
|
23
23
|
size: '站点响应超过允许大小。',
|
|
24
24
|
identity: '站点返回的用户 ID 与配置不一致,已停止操作。',
|
|
25
25
|
credentials: '请填写所选认证方式对应的系统访问令牌或 Cookie。',
|
|
26
|
+
login_credentials: '请填写 AgentRouter 用户名和密码。',
|
|
26
27
|
disabled: '该账号或签到功能未启用。',
|
|
27
28
|
checkin: '签到未完成,请前往站点检查签到条件。',
|
|
28
29
|
cookie_required: '当前令牌缺少签到权限或站点要求登录 Cookie,请检查令牌权限或改用 Cookie。',
|
|
@@ -31,10 +32,11 @@ const ERRORS = Object.freeze({
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
export class NewApiError extends Error {
|
|
34
|
-
constructor(code = 'network') {
|
|
35
|
+
constructor(code = 'network', status = null) {
|
|
35
36
|
super(ERRORS[code] ?? ERRORS.network);
|
|
36
37
|
this.name = 'NewApiError';
|
|
37
38
|
this.code = Object.hasOwn(ERRORS, code) ? code : 'network';
|
|
39
|
+
this.status = Number.isInteger(status) ? status : null;
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -43,6 +45,22 @@ export function safeNewApiMessage(error) {
|
|
|
43
45
|
return error instanceof NewApiError ? ERRORS[error.code] ?? ERRORS.network : ERRORS.network;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/** Keep only the site's session cookie pair; never persist unrelated Set-Cookie values. */
|
|
49
|
+
function sessionCookie(headers) {
|
|
50
|
+
const combined = headers.get('set-cookie');
|
|
51
|
+
const values = typeof headers.getSetCookie === 'function'
|
|
52
|
+
? headers.getSetCookie()
|
|
53
|
+
: combined ? combined.split(/,(?=\s*[^;=,\s]+=)/u) : [];
|
|
54
|
+
for (const value of values) {
|
|
55
|
+
const pair = String(value).split(';')[0].trim();
|
|
56
|
+
const separator = pair.indexOf('=');
|
|
57
|
+
if (separator <= 0 || pair.slice(0, separator).trim().toLowerCase() !== 'session') continue;
|
|
58
|
+
if (CONTROL.test(pair) || pair.length > 32768) continue;
|
|
59
|
+
return pair;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
/** Whitelist the account config; no model-router headers are accepted here. */
|
|
47
65
|
export function normalizeAccountConfig(raw = {}) {
|
|
48
66
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new AccountValidationError();
|
|
@@ -59,18 +77,25 @@ export function normalizeAccountConfig(raw = {}) {
|
|
|
59
77
|
if (raw[key] !== undefined && typeof raw[key] !== 'boolean') fields[key] = '必须为开关值。';
|
|
60
78
|
return raw[key] === undefined ? true : raw[key] === true;
|
|
61
79
|
}
|
|
80
|
+
const siteType = text('siteType', 'newapi', 16);
|
|
81
|
+
if (!['newapi', 'agentrouter'].includes(siteType)) fields.siteType = '请选择 New API 或 AgentRouter。';
|
|
62
82
|
let siteUrl = text('siteUrl', '', 2048);
|
|
63
83
|
try {
|
|
64
84
|
const url = new URL(siteUrl);
|
|
65
85
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || siteUrl.includes('?') || siteUrl.includes('#') || siteUrl.includes('\\') || /%(?:0[0-9a-f]|1[0-9a-f]|7f)/iu.test(siteUrl)) throw new Error();
|
|
86
|
+
const loopback = /^127(?:\.\d{1,3}){3}$/u.test(url.hostname) || ['localhost', '::1', '[::1]'].includes(url.hostname.toLowerCase());
|
|
66
87
|
siteUrl = url.href.replace(/\/+$/u, '');
|
|
88
|
+
if (siteType === 'agentrouter' && url.protocol === 'http:' && !loopback) fields.siteUrl = 'AgentRouter 必须使用 HTTPS 或本机回环地址。';
|
|
67
89
|
} catch { fields.siteUrl = '请输入不含账号、密码、查询参数或片段的 HTTP(S) 站点地址。'; }
|
|
68
90
|
const userId = text('userId', '', 32);
|
|
69
|
-
if (!/^[1-9]\d*$/u.test(userId)) fields.userId = '用户 ID 必须为正整数字符串。';
|
|
70
|
-
const name = text('name', 'New API', 100);
|
|
91
|
+
if (siteType === 'newapi' && !/^[1-9]\d*$/u.test(userId) || siteType === 'agentrouter' && userId && !/^[1-9]\d*$/u.test(userId)) fields.userId = '用户 ID 必须为正整数字符串。';
|
|
92
|
+
const name = text('name', siteType === 'agentrouter' ? 'AgentRouter' : 'New API', 100);
|
|
71
93
|
if (!name) fields.name = '请输入账号名称。';
|
|
72
94
|
const authMode = text('authMode', 'token', 16);
|
|
73
95
|
if (!['token', 'cookie'].includes(authMode)) fields.authMode = '请选择令牌或 Cookie 认证。';
|
|
96
|
+
const username = text('username', '', 256);
|
|
97
|
+
const password = raw.password ?? '';
|
|
98
|
+
if (typeof password !== 'string' || CONTROL.test(password) || password.length > 4096) fields.password = '请输入不含控制字符且长度合理的密钥。';
|
|
74
99
|
const accessToken = text('accessToken', '', 16384);
|
|
75
100
|
const cookie = text('cookie', '', 32768);
|
|
76
101
|
const quotaPerUnit = raw.quotaPerUnit ?? 500000;
|
|
@@ -92,23 +117,37 @@ export function normalizeAccountConfig(raw = {}) {
|
|
|
92
117
|
for (const field of Object.keys(error.fields)) fields[field] = messages[field] ?? messages.schedule;
|
|
93
118
|
}
|
|
94
119
|
if (Object.keys(fields).length) throw new AccountValidationError('账号配置无效。', 400, fields);
|
|
95
|
-
return { name, enabled, siteUrl, userId, authMode, accessToken, cookie, quotaPerUnit, unit, checkinEnabled, schedule };
|
|
120
|
+
return { name, enabled, siteUrl, siteType, userId, username, password, authMode, accessToken, cookie, quotaPerUnit, unit, checkinEnabled, schedule };
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
export function createNewApiClient({ fetchImpl = globalThis.fetch, now = () => new Date() } = {}) {
|
|
99
124
|
if (typeof fetchImpl !== 'function') throw new AccountValidationError('缺少网络请求实现。');
|
|
100
125
|
const timestamp = () => new Date(now()).toISOString();
|
|
101
126
|
|
|
102
|
-
async function request(account, endpoint, { method = 'GET', body } = {}) {
|
|
127
|
+
async function request(account, endpoint, { method = 'GET', body, accountAuth = true, cookieJar } = {}) {
|
|
103
128
|
const url = new URL(`${account.siteUrl}${endpoint}`);
|
|
104
129
|
if (url.origin !== new URL(account.siteUrl).origin) throw new NewApiError('redirect');
|
|
105
|
-
const headers = { Accept: 'application/json'
|
|
106
|
-
if (
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
130
|
+
const headers = { Accept: 'application/json' };
|
|
131
|
+
if (accountAuth) {
|
|
132
|
+
if (account.siteType === 'agentrouter') {
|
|
133
|
+
if (account.userId) headers['New-Api-User'] = account.userId;
|
|
134
|
+
if (account.authMode === 'token') {
|
|
135
|
+
if (!account.accessToken) throw new NewApiError('credentials');
|
|
136
|
+
headers.Authorization = account.accessToken;
|
|
137
|
+
} else {
|
|
138
|
+
if (!account.cookie) throw new NewApiError('credentials');
|
|
139
|
+
headers.Cookie = account.cookie;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
headers['New-Api-User'] = account.userId;
|
|
143
|
+
if (account.authMode === 'token') {
|
|
144
|
+
if (!account.accessToken) throw new NewApiError('credentials');
|
|
145
|
+
headers.Authorization = `Bearer ${account.accessToken}`;
|
|
146
|
+
} else {
|
|
147
|
+
if (!account.cookie) throw new NewApiError('credentials');
|
|
148
|
+
headers.Cookie = account.cookie;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
112
151
|
}
|
|
113
152
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
114
153
|
const controller = new AbortController();
|
|
@@ -124,11 +163,15 @@ export function createNewApiClient({ fetchImpl = globalThis.fetch, now = () => n
|
|
|
124
163
|
try {
|
|
125
164
|
return await Promise.race([deadline, (async () => {
|
|
126
165
|
const response = await fetchImpl(url.href, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer', signal: controller.signal });
|
|
127
|
-
const rejectResponse = code => { void response.body?.cancel().catch(() => {}); throw new NewApiError(code); };
|
|
166
|
+
const rejectResponse = (code, status = response.status) => { void response.body?.cancel().catch(() => {}); throw new NewApiError(code, status); };
|
|
128
167
|
if (response.redirected || response.status >= 300 && response.status < 400 || response.url && new URL(response.url).origin !== url.origin) rejectResponse('redirect');
|
|
129
168
|
if (response.status === 401 || response.status === 403) rejectResponse('auth');
|
|
130
169
|
if (!response.ok) rejectResponse('http');
|
|
131
170
|
if (Number(response.headers.get('content-length')) > RESPONSE_LIMIT) rejectResponse('size');
|
|
171
|
+
if (cookieJar && typeof cookieJar === 'object') {
|
|
172
|
+
const cookie = sessionCookie(response.headers);
|
|
173
|
+
if (cookie) cookieJar.cookie = cookie;
|
|
174
|
+
}
|
|
132
175
|
if (!response.body?.getReader) throw new NewApiError('response');
|
|
133
176
|
reader = response.body.getReader();
|
|
134
177
|
const chunks = []; let size = 0;
|
|
@@ -151,8 +194,24 @@ export function createNewApiClient({ fetchImpl = globalThis.fetch, now = () => n
|
|
|
151
194
|
} finally { clearTimeout(timer); }
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
async function agentRouterBalance(account) {
|
|
198
|
+
if (!/^[1-9]\d*$/u.test(account.userId)) throw new NewApiError('identity');
|
|
199
|
+
if (account.authMode === 'token' ? !account.accessToken : !account.cookie) throw new NewApiError('credentials');
|
|
200
|
+
const result = await request(account, '/api/user/self');
|
|
201
|
+
if (result.success !== true || !result.data || typeof result.data !== 'object') throw new NewApiError('response');
|
|
202
|
+
const data = result.data;
|
|
203
|
+
const id = typeof data.id === 'number' && Number.isSafeInteger(data.id) ? String(data.id)
|
|
204
|
+
: typeof data.id === 'string' && /^[1-9]\d*$/u.test(data.id) ? data.id : null;
|
|
205
|
+
if (!id || id !== account.userId) throw new NewApiError('identity');
|
|
206
|
+
if (!Number.isSafeInteger(data.quota) || !Number.isSafeInteger(data.used_quota) || data.used_quota < 0) throw new NewApiError('response');
|
|
207
|
+
const requestCount = data.request_count === undefined || data.request_count === null ? 0 : data.request_count;
|
|
208
|
+
if (!Number.isSafeInteger(requestCount) || requestCount < 0) throw new NewApiError('response');
|
|
209
|
+
return { at: timestamp(), quota: data.quota, usedQuota: data.used_quota, requestCount };
|
|
210
|
+
}
|
|
211
|
+
|
|
154
212
|
async function balance(raw) {
|
|
155
213
|
const account = normalizeAccountConfig(raw);
|
|
214
|
+
if (account.siteType === 'agentrouter') return agentRouterBalance(account);
|
|
156
215
|
const result = await request(account, '/api/user/self');
|
|
157
216
|
if (result.success !== true || !result.data || typeof result.data !== 'object') throw new NewApiError('response');
|
|
158
217
|
const data = result.data;
|
|
@@ -162,9 +221,43 @@ export function createNewApiClient({ fetchImpl = globalThis.fetch, now = () => n
|
|
|
162
221
|
return { at: timestamp(), quota: data.quota, usedQuota: data.used_quota, requestCount: data.request_count };
|
|
163
222
|
}
|
|
164
223
|
|
|
224
|
+
async function agentRouterCheckin(account) {
|
|
225
|
+
if (!account.username || !account.password) throw new NewApiError('login_credentials');
|
|
226
|
+
const cookieJar = {};
|
|
227
|
+
let result;
|
|
228
|
+
try {
|
|
229
|
+
result = await request(account, '/api/user/login', {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
body: { username: account.username, password: account.password },
|
|
232
|
+
accountAuth: false,
|
|
233
|
+
cookieJar,
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!(error instanceof NewApiError)) throw new NewApiError('ambiguous');
|
|
237
|
+
if (['credentials', 'identity', 'redirect', 'auth'].includes(error.code)) throw error;
|
|
238
|
+
// A 4xx response is a definite rejection; a 5xx/body/transport failure may
|
|
239
|
+
// have happened after the site completed the login.
|
|
240
|
+
if (error.code === 'http' && Number.isInteger(error.status) && error.status >= 400 && error.status < 500) {
|
|
241
|
+
throw new NewApiError('checkin', error.status);
|
|
242
|
+
}
|
|
243
|
+
throw new NewApiError('ambiguous', error.status);
|
|
244
|
+
}
|
|
245
|
+
if (result.success === false) throw new NewApiError('checkin');
|
|
246
|
+
if (result.success !== true) throw new NewApiError('ambiguous');
|
|
247
|
+
if (!result.data || typeof result.data !== 'object') throw new NewApiError('ambiguous');
|
|
248
|
+
const data = result.data;
|
|
249
|
+
if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id <= 0) throw new NewApiError('identity');
|
|
250
|
+
const discovered = String(data.id);
|
|
251
|
+
if (account.userId && account.userId !== discovered) throw new NewApiError('identity');
|
|
252
|
+
if (data.checked_in !== undefined && data.checked_in !== true && data.checked_in !== false) throw new NewApiError('ambiguous');
|
|
253
|
+
return { state: data.checked_in === true ? 'success' : 'already', date: null, rewardQuota: null,
|
|
254
|
+
userId: discovered, cookie: cookieJar.cookie, authenticated: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
165
257
|
async function checkin(raw, { turnstileToken } = {}) {
|
|
166
258
|
const account = normalizeAccountConfig(raw);
|
|
167
259
|
if (!account.enabled || !account.checkinEnabled) throw new NewApiError('disabled');
|
|
260
|
+
if (account.siteType === 'agentrouter') return agentRouterCheckin(account);
|
|
168
261
|
if (turnstileToken !== undefined && (typeof turnstileToken !== 'string' || CONTROL.test(turnstileToken) || turnstileToken.length > 4096)) throw new AccountValidationError('验证码参数无效。');
|
|
169
262
|
await balance(account);
|
|
170
263
|
const site = await request(account, '/api/status');
|
package/src/plugin.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { createConnectionDiagnostics } from './diagnostics.mjs';
|
|
|
15
15
|
import { createAccountProfileStore } from './account-profile.mjs';
|
|
16
16
|
import { isDeepStrictEqual } from 'node:util';
|
|
17
17
|
import { pluginOptions, optionProfile } from './options.mjs';
|
|
18
|
+
import { accountUsage, accountCheckin } from './account-usage.mjs';
|
|
18
19
|
|
|
19
20
|
export const PROVIDER_ID = 'claude-code-ua';
|
|
20
21
|
function modelsFor(profile) {
|
|
@@ -111,7 +112,7 @@ export async function createPlugin(input, rawOptions, dependencies = {}) {
|
|
|
111
112
|
|
|
112
113
|
const uaStore = dependencies.uaStore ?? createUAStore(join(dirname(store.path), 'ua-pro.json'));
|
|
113
114
|
const uaService = dependencies.uaService ?? createUAService({
|
|
114
|
-
store: uaStore, legacyStore: store,
|
|
115
|
+
store: uaStore, legacyStore: store, accountManager,
|
|
115
116
|
readAuth: (provider, account) => currentHostAuth(account || provider),
|
|
116
117
|
fetchImpl: dependencies.fetchImpl ?? globalThis.fetch,
|
|
117
118
|
});
|
|
@@ -274,47 +275,16 @@ export async function createPlugin(input, rawOptions, dependencies = {}) {
|
|
|
274
275
|
let account;
|
|
275
276
|
try { account = await accountManager.refresh(id); }
|
|
276
277
|
catch { return { signIn: 'kept', windows: [{ name: '可选余额', display: '绑定的站点账号不可用,请检查账号管理', aside: true }] }; }
|
|
277
|
-
return
|
|
278
|
-
plan: 'New API',
|
|
279
|
-
user: account.name,
|
|
280
|
-
signIn: 'kept',
|
|
281
|
-
windows: [account].map((account) => {
|
|
282
|
-
const snapshot = account.snapshot;
|
|
283
|
-
const amount = (value) => value === null || value === undefined ? '—' : Number(value).toLocaleString('en-US', { maximumFractionDigits: 6 });
|
|
284
|
-
return {
|
|
285
|
-
name: account.name,
|
|
286
|
-
aside: true,
|
|
287
|
-
unit: account.unit,
|
|
288
|
-
...(snapshot ? { balance: snapshot.remaining } : {}),
|
|
289
|
-
display: snapshot
|
|
290
|
-
? `余额 ${amount(snapshot.remaining)} ${account.unit};累计消耗 ${amount(snapshot.used)};消耗增量 ${amount(snapshot.deltaUsed)};余额变化 ${amount(snapshot.deltaBalance)}${account.status.stale ? '(缓存)' : ''}${account.status.error ? `;${account.status.error}` : ''}`
|
|
291
|
-
: account.status.error || '尚未读取余额',
|
|
292
|
-
};
|
|
293
|
-
}),
|
|
294
|
-
};
|
|
278
|
+
return accountUsage(account);
|
|
295
279
|
},
|
|
296
280
|
async checkin(getAuth) {
|
|
297
281
|
const id = await boundAccount(getAuth);
|
|
298
282
|
if (!id) return { outcome: 'inactive', message: '当前宿主账号未绑定站点账号,未发起签到' };
|
|
299
283
|
let account;
|
|
300
284
|
try {
|
|
301
|
-
const configured = await accountManager.get?.(id);
|
|
302
|
-
if (configured?.schedule?.enabled) return { outcome: 'inactive', message: `此账号由插件按每天 ${configured.schedule.time}(${configured.schedule.timeZone})自动签到;如需现在执行,请在账号管理页手动签到` };
|
|
303
285
|
account = await accountManager.checkin(id);
|
|
304
286
|
} catch { return { outcome: 'failed', message: '绑定的站点账号签到未完成,请检查账号管理' }; }
|
|
305
|
-
|
|
306
|
-
if (!active.length) return { outcome: 'inactive', message: '当前账号未启用签到' };
|
|
307
|
-
const success = new Set(['success', 'claimed']);
|
|
308
|
-
const done = new Set(['already', 'already_checked', 'checked', 'done']);
|
|
309
|
-
const captcha = new Set(['requires_verification', 'verification_required', 'captcha']);
|
|
310
|
-
const failed = active.some((account) => !success.has(account.checkin.state) && !done.has(account.checkin.state) && !captcha.has(account.checkin.state) && account.checkin.state !== 'disabled');
|
|
311
|
-
const needsCaptcha = active.some((account) => captcha.has(account.checkin.state));
|
|
312
|
-
const claimed = active.some((account) => success.has(account.checkin.state));
|
|
313
|
-
return {
|
|
314
|
-
outcome: failed ? 'failed' : needsCaptcha ? 'captcha' : claimed ? 'claimed' : active.every((account) => account.checkin.state === 'disabled') ? 'inactive' : 'done',
|
|
315
|
-
...(claimed && Number.isFinite(account.checkin.reward) ? { credit: account.checkin.reward } : {}),
|
|
316
|
-
message: active.map((account) => `${account.name}:${success.has(account.checkin.state) ? `签到成功${account.checkin.reward == null ? '' : `,奖励 ${account.checkin.reward} ${account.unit}`}` : done.has(account.checkin.state) ? '今日已签到' : captcha.has(account.checkin.state) ? '需要在站点完成验证' : account.checkin.state === 'disabled' ? '站点未开启签到' : `签到失败(${account.checkin.message || '未完成'})`}`).join(';'),
|
|
317
|
-
};
|
|
287
|
+
return accountCheckin(account);
|
|
318
288
|
},
|
|
319
289
|
async loader(getAuth, provider) {
|
|
320
290
|
const auth = await getAuth();
|
package/src/scheduler.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const schedulers = new Map();
|
|
|
7
7
|
|
|
8
8
|
function identityOf(account) {
|
|
9
9
|
const url = new URL(account.siteUrl);
|
|
10
|
-
return `${url.origin}${url.pathname.replace(/\/+$/, '')}\n${account.userId}`;
|
|
10
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}\n${account.userId || `username:${account.username}`}`;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function createCheckinScheduler({
|
|
@@ -78,7 +78,7 @@ export function createCheckinScheduler({
|
|
|
78
78
|
if (!occurrence) return false;
|
|
79
79
|
const identity = identityOf(account);
|
|
80
80
|
if (accounts.some((other) => {
|
|
81
|
-
const checkedAt = Date.parse(other.checkin?.checkedAt);
|
|
81
|
+
const checkedAt = Date.parse(other.checkin?.lastAttemptAt ?? other.checkin?.checkedAt);
|
|
82
82
|
return Number.isFinite(checkedAt) && identityOf(other) === identity && scheduleDate(checkedAt, occurrence.timeZone) === occurrence.date;
|
|
83
83
|
})) return false;
|
|
84
84
|
if (identities.has(identity)) return false;
|
package/src/setup-server.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { createConnectionDiagnostics } from './diagnostics.mjs';
|
|
5
5
|
import { CLIENT_PRESETS, DEFAULT_METADATA_TEMPLATES, VARIABLE_DEFINITIONS, redactProfile } from './profile.mjs';
|
|
6
6
|
|
|
7
|
+
const application = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
7
8
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
8
9
|
// Fetch/browser bad-port list: a random listener must still be browser reachable.
|
|
9
10
|
const BLOCKED_PORTS = new Set([1,7,9,11,13,15,17,19,20,21,22,23,25,37,42,43,53,69,77,79,87,95,101,102,103,104,109,110,111,113,115,117,119,123,135,137,139,143,161,179,389,427,465,512,513,514,515,526,530,531,532,540,548,554,556,563,587,601,636,989,990,993,995,1719,1720,1723,2049,3659,4045,4190,5060,5061,6000,6566,6665,6666,6667,6668,6669,6697,10080]);
|
|
@@ -235,7 +236,7 @@ export async function startSetupServer({
|
|
|
235
236
|
if (request.method !== 'GET') return json(response, 405, { error: '不支持此请求方法' });
|
|
236
237
|
const state = await uaService.state();
|
|
237
238
|
if (settled) return ended(response);
|
|
238
|
-
return json(response, 200, { ...state, session: sessionInfo() });
|
|
239
|
+
return json(response, 200, { ...state, application: { name: 'UA PRO', version: application.version, provider: 'ua-pro' }, session: sessionInfo() });
|
|
239
240
|
}
|
|
240
241
|
if (request.method !== 'POST') return json(response, 405, { error: '不支持此请求方法' });
|
|
241
242
|
if (request.headers.origin !== origin) return json(response, 403, { error: '操作必须从当前配置页面发起' });
|
|
@@ -302,11 +303,12 @@ export async function startSetupServer({
|
|
|
302
303
|
if (settled) return ended(response);
|
|
303
304
|
if (!accountManager) return json(response, 503, { error: '账号管理尚未初始化' });
|
|
304
305
|
const accounts = await accountManager.list();
|
|
306
|
+
const connections = uaService?.accountConnections ? await uaService.accountConnections() : [];
|
|
305
307
|
if (settled) return ended(response);
|
|
306
|
-
return json(response, 200, { ...accounts, ...(hostBinding ? { binding: { account: hostBinding.account, selected: hostBinding.selected } } : {}) });
|
|
308
|
+
return json(response, 200, { ...accounts, connections, ...(hostBinding ? { binding: { account: hostBinding.account, selected: hostBinding.selected } } : {}) });
|
|
307
309
|
}
|
|
308
310
|
const accountAction = route.startsWith('api/accounts/') ? route.slice('api/accounts/'.length) : null;
|
|
309
|
-
if (accountAction && !['save', 'remove', 'refresh', 'checkin', 'bind', 'done'].includes(accountAction)) return json(response, 404, { error: '账号接口不存在' });
|
|
311
|
+
if (accountAction && !['save', 'remove', 'refresh', 'checkin', 'bind', 'done', 'connection-usage'].includes(accountAction)) return json(response, 404, { error: '账号接口不存在' });
|
|
310
312
|
if (!accountAction && !['api/save', 'api/check', 'api/cancel'].includes(route)) return json(response, 404, { error: '配置接口不存在' });
|
|
311
313
|
if (request.method !== 'POST') return json(response, 405, { error: '不支持此请求方法' });
|
|
312
314
|
if (request.headers.origin !== origin) return json(response, 403, { error: '保存必须从当前配置页面发起' });
|
|
@@ -332,6 +334,17 @@ export async function startSetupServer({
|
|
|
332
334
|
if (settled) return ended(response, 409);
|
|
333
335
|
if (saving) return busy(response);
|
|
334
336
|
if (accountAction) {
|
|
337
|
+
if (accountAction === 'connection-usage') {
|
|
338
|
+
if (!uaService) return json(response, 503, { error: '连接余额查询尚未初始化' });
|
|
339
|
+
try {
|
|
340
|
+
const result = await uaService.execute('connections/usage', data);
|
|
341
|
+
if (settled) return ended(response);
|
|
342
|
+
return json(response, 200, result);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
const status = [400, 404, 409].includes(error.status) ? error.status : 500;
|
|
345
|
+
return json(response, status, { error: status === 500 ? '连接余额读取未完成,请核对连接状态' : error.message });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
335
348
|
if (!accountManager) return json(response, 503, { error: '账号管理尚未初始化' });
|
|
336
349
|
if (accountAction === 'done') {
|
|
337
350
|
json(response, 200, { done: true });
|
package/src/ua-plugin.mjs
CHANGED
|
@@ -12,13 +12,15 @@ import { createProfileStore } from './store.mjs'
|
|
|
12
12
|
import { createAccountManager } from './accounts.mjs'
|
|
13
13
|
import { ensureCheckinScheduler } from './scheduler.mjs'
|
|
14
14
|
import { isDeepStrictEqual } from 'node:util'
|
|
15
|
-
import {
|
|
15
|
+
import { createUAUsage } from './ua-usage.mjs'
|
|
16
|
+
import { createKeyUsage } from './key-usage.mjs'
|
|
16
17
|
import { resolveProfilePath } from './runtime.mjs'
|
|
17
18
|
|
|
18
19
|
const PREFIX = 'ua-pro:connection:'
|
|
19
20
|
const observed = new Map()
|
|
20
21
|
const stores = new Map()
|
|
21
22
|
const modelRoutes = new Map()
|
|
23
|
+
const loadedProviders = new Map()
|
|
22
24
|
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 20)
|
|
23
25
|
export function uaStorePath(options = {}, env = process.env) {
|
|
24
26
|
const path = options.uaFile ?? env.UA_PRO_FILE ?? fileURLToPath(new URL('../runtime/ua-pro.json', import.meta.url))
|
|
@@ -39,6 +41,8 @@ export async function createUAPlugin(input = {}, options = {}, dependencies = {}
|
|
|
39
41
|
const store = dependencies.uaStore ?? sharedStore(path)
|
|
40
42
|
await store.initialize()
|
|
41
43
|
const id = siteId ? `ua-pro-${siteId}` : 'ua-pro'
|
|
44
|
+
if (!loadedProviders.has(store.path)) loadedProviders.set(store.path, new Set())
|
|
45
|
+
loadedProviders.get(store.path).add(id)
|
|
42
46
|
const legacyStore = dependencies.legacyStore ?? createProfileStore(resolveProfilePath({ configPath: options.profileFile }))
|
|
43
47
|
const accountManager = dependencies.accountManager ?? createAccountManager({ path: join(dirname(path), 'accounts.json') })
|
|
44
48
|
if (input.magpie && !siteId) (dependencies.startScheduler ?? ensureCheckinScheduler)({ accountManager, stateDirectory: join(dirname(path), 'schedule-claims'), onError: () => {} })
|
|
@@ -53,16 +57,44 @@ export async function createUAPlugin(input = {}, options = {}, dependencies = {}
|
|
|
53
57
|
catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('无法读取宿主账号,停止操作以保护已有凭据') }
|
|
54
58
|
}
|
|
55
59
|
const readAuth = async (provider, account) => (await auths())[account || provider]
|
|
56
|
-
const
|
|
57
|
-
if (!input.client?.auth?.set || connection.auth?.source === 'host') return
|
|
60
|
+
const connectionStatus = async (connection, all = {}) => {
|
|
58
61
|
const provider = providerOf(connection)
|
|
59
|
-
const account = `${provider}#${digest(connection.id)}`
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
const account = connection.auth?.source === 'host' ? connection.auth.account : `${provider}#${digest(connection.id)}`
|
|
63
|
+
const base = { connectionId: connection.id, provider, account, name: connection.name }
|
|
64
|
+
if (!connection.enabled) return { ...base, status: 'disabled' }
|
|
65
|
+
if (!loadedProviders.get(store.path)?.has(provider)) return { ...base, status: 'reload-required', message: '新增独立站点需要在 Magpie 重载此插件后载入' }
|
|
66
|
+
if (connection.auth?.source === 'host') {
|
|
67
|
+
const saved = all[account]
|
|
68
|
+
const endpoint = saved?.metadata?.baseURL ?? saved?.metadata?.endpoint
|
|
69
|
+
if (saved?.type !== 'api' || !saved.key || !endpoint || siteRoot(endpoint) !== siteRoot(connection.baseURL)) return { ...base, status: 'unregistered', message: '该连接引用的宿主账号尚未确认,请重新打开账号配置' }
|
|
70
|
+
return { ...base, status: 'registered' }
|
|
71
|
+
}
|
|
72
|
+
const saved = all[account]
|
|
73
|
+
if (saved && (saved.type !== 'api' || saved.key !== PREFIX + connection.id)) return { ...base, status: 'conflict', message: '目标宿主账号已被其他凭据占用,未覆盖' }
|
|
74
|
+
return { ...base, status: saved ? 'registered' : 'unregistered' }
|
|
75
|
+
}
|
|
76
|
+
const register = async connection => {
|
|
77
|
+
const all = await auths()
|
|
78
|
+
const current = await connectionStatus(connection, all)
|
|
79
|
+
if (current.status === 'conflict') throw new Error(current.message)
|
|
80
|
+
if (['disabled', 'reload-required'].includes(current.status) || connection.auth?.source === 'host') return current
|
|
81
|
+
if (!input.client?.auth?.set) return { ...current, status: 'host-unavailable', message: '请从 Magpie 打开管理页后同步连接' }
|
|
82
|
+
const existing = all[current.account]
|
|
83
|
+
if (current.status === 'registered' && existing.accountId === connection.name && existing.metadata?.uaConnectionId === connection.id) return current
|
|
84
|
+
const body = { ...existing, type: 'api', key: PREFIX + connection.id, accountId: connection.name, metadata: { ...existing?.metadata, uaConnectionId: connection.id } }
|
|
85
|
+
const response = await input.client.auth.set({ path: { id: current.account }, body })
|
|
86
|
+
const confirmed = (await auths())[current.account]
|
|
87
|
+
if (response?.data !== true || !isDeepStrictEqual(confirmed, body)) throw new Error('宿主未确认连接注册,请重载并核对')
|
|
88
|
+
return { ...current, status: 'registered' }
|
|
89
|
+
}
|
|
90
|
+
const syncConnections = async () => {
|
|
91
|
+
const snapshot = await store.snapshot()
|
|
92
|
+
const connections = []
|
|
93
|
+
for (const connection of snapshot.connections) {
|
|
94
|
+
try { connections.push(await register(connection)) }
|
|
95
|
+
catch { connections.push({ connectionId: connection.id, provider: providerOf(connection), status: 'failed', message: '宿主同步未完成;配置和凭据保留,请重载插件后核对' }) }
|
|
96
|
+
}
|
|
97
|
+
return { connections, message: connections.some(item => item.status === 'reload-required') ? '已同步可用账号;新增独立站点需要重载 UA PRO 插件' : '宿主同步结果已核对' }
|
|
66
98
|
}
|
|
67
99
|
const routeKey = scope => `${store.path}:${scope}`
|
|
68
100
|
const modelFetchFor = scope => {
|
|
@@ -73,7 +105,13 @@ export async function createUAPlugin(input = {}, options = {}, dependencies = {}
|
|
|
73
105
|
return fetchImpl
|
|
74
106
|
}
|
|
75
107
|
const service = createUAService({ store, legacyStore, accountManager, fetchImpl, readAuth, modelCatalog, modelFetchFor, onConnectionSaved: input.client?.auth?.set ? register : undefined,
|
|
76
|
-
|
|
108
|
+
syncConnections: input.client?.auth?.set ? syncConnections : undefined,
|
|
109
|
+
runtime: async snapshot => {
|
|
110
|
+
const all = await auths()
|
|
111
|
+
return { mode: input.magpie ? 'host' : 'standalone', storagePath: store.path,
|
|
112
|
+
connections: await Promise.all((snapshot?.connections ?? []).map(connection => connectionStatus(connection, all))),
|
|
113
|
+
observed: [...observed.values()].filter(item => item.provider === id) }
|
|
114
|
+
} })
|
|
77
115
|
let declared = {}
|
|
78
116
|
const getNativeConnection = async (auth, provider = {}) => {
|
|
79
117
|
if (auth?.type !== 'api' || typeof auth.key !== 'string' || !auth.key.trim()) return null
|
|
@@ -129,7 +167,12 @@ export async function createUAPlugin(input = {}, options = {}, dependencies = {}
|
|
|
129
167
|
} finally { if (stop) signal.removeEventListener('abort', stop); await session.close() }
|
|
130
168
|
} }
|
|
131
169
|
}
|
|
132
|
-
const
|
|
170
|
+
const resolveAccount = async auth => {
|
|
171
|
+
const matches = Object.entries(await auths()).filter(([slot, saved]) => (slot === id || slot.startsWith(id + '#')) && isDeepStrictEqual(saved, auth))
|
|
172
|
+
return matches.length === 1 ? matches[0][0] : null
|
|
173
|
+
}
|
|
174
|
+
const usageHooks = createUAUsage({ providerId: id, input, options, accountManager, getConnection: getNativeConnection, resolveAccount,
|
|
175
|
+
queryKeyUsage: (connection, auth) => createKeyUsage({ fetchImpl, readAuth: executeWithAuth(auth, connection) })(connection) })
|
|
133
176
|
return {
|
|
134
177
|
async config(config) {
|
|
135
178
|
const snapshot = await store.snapshot({ secrets: true })
|
|
@@ -162,8 +205,8 @@ export async function createUAPlugin(input = {}, options = {}, dependencies = {}
|
|
|
162
205
|
return transport(url, { ...init, headers })
|
|
163
206
|
} }
|
|
164
207
|
},
|
|
165
|
-
usage: usageHooks.
|
|
166
|
-
checkin: usageHooks.
|
|
208
|
+
usage: usageHooks.usage,
|
|
209
|
+
checkin: usageHooks.checkin,
|
|
167
210
|
},
|
|
168
211
|
provider: { id, async models(provider = {}, { auth } = {}) {
|
|
169
212
|
const connection = await getNativeConnection(auth, provider)
|
package/src/ua-runtime.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export function operationForURL(input) {
|
|
|
18
18
|
throw err('请求路径不是支持的模型操作')
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
async function credential(connection, readAuth) {
|
|
21
|
+
export async function credential(connection, readAuth) {
|
|
22
22
|
let auth = connection.auth ?? {}
|
|
23
23
|
if (auth.source === 'host') {
|
|
24
24
|
const current = await readAuth?.(auth.provider, auth.account)
|