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.
@@ -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', 'New-Api-User': account.userId };
106
- if (account.authMode === 'token') {
107
- if (!account.accessToken) throw new NewApiError('credentials');
108
- headers.Authorization = `Bearer ${account.accessToken}`;
109
- } else {
110
- if (!account.cookie) throw new NewApiError('credentials');
111
- headers.Cookie = account.cookie;
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
- const active = [account].filter((account) => account.enabled && account.checkinEnabled);
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;
@@ -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 { createPlugin as createLegacyPlugin } from './plugin.mjs'
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 register = async connection => {
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 existing = (await auths())[account]
61
- const key = PREFIX + connection.id
62
- if (existing && existing.key !== key) throw new Error('目标宿主账号已被其他凭据占用')
63
- if (existing) return
64
- const response = await input.client.auth.set({ path: { id: account }, body: { type: 'api', key, accountId: connection.name, metadata: { uaConnectionId: connection.id } } })
65
- if (response?.data !== true) throw new Error('宿主未确认连接注册,请重载并核对')
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
- runtime: () => ({ mode: input.magpie ? 'host' : 'standalone', observed: [...observed.values()].filter(item => item.provider === id) }) })
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 usageHooks = await createLegacyPlugin({ ...input, magpie: undefined }, { profileFile: legacyStore.path, ...(options.accountBindings ? { accountBindings: options.accountBindings } : {}) }, { store: legacyStore, accountManager })
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.auth.usage,
166
- checkin: usageHooks.auth.checkin,
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)
@@ -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)