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/src/newapi.mjs ADDED
@@ -0,0 +1,237 @@
1
+ import { normalizeSchedule, ScheduleValidationError } from './schedule.mjs';
2
+
3
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u;
4
+ const RESPONSE_LIMIT = 1024 * 1024;
5
+ const TIMEOUT_MS = 15000;
6
+
7
+ export class AccountValidationError extends Error {
8
+ constructor(message = '账号配置无效。', status = 400, fields = {}) {
9
+ super(message);
10
+ this.name = 'AccountValidationError';
11
+ this.status = status;
12
+ this.fields = fields;
13
+ }
14
+ }
15
+
16
+ const ERRORS = Object.freeze({
17
+ timeout: '站点请求超时,请稍后手动重试。',
18
+ network: '无法连接站点,请检查地址与网络。',
19
+ redirect: '站点返回了重定向;为保护凭据,已停止请求。',
20
+ auth: '站点认证失败,请检查系统访问令牌、Cookie 和用户 ID。',
21
+ http: '站点请求失败,请在站点确认服务状态。',
22
+ response: '站点返回了无效的数据。',
23
+ size: '站点响应超过允许大小。',
24
+ identity: '站点返回的用户 ID 与配置不一致,已停止操作。',
25
+ credentials: '请填写所选认证方式对应的系统访问令牌或 Cookie。',
26
+ disabled: '该账号或签到功能未启用。',
27
+ checkin: '签到未完成,请前往站点检查签到条件。',
28
+ cookie_required: '当前令牌缺少签到权限或站点要求登录 Cookie,请检查令牌权限或改用 Cookie。',
29
+ turnstile: '站点要求验证码,请前往站点完成验证后签到。',
30
+ ambiguous: '签到结果未确认;不会自动重试,请在站点确认记录。',
31
+ });
32
+
33
+ export class NewApiError extends Error {
34
+ constructor(code = 'network') {
35
+ super(ERRORS[code] ?? ERRORS.network);
36
+ this.name = 'NewApiError';
37
+ this.code = Object.hasOwn(ERRORS, code) ? code : 'network';
38
+ }
39
+ }
40
+
41
+ /** Only fixed local messages cross the network/client boundary. */
42
+ export function safeNewApiMessage(error) {
43
+ return error instanceof NewApiError ? ERRORS[error.code] ?? ERRORS.network : ERRORS.network;
44
+ }
45
+
46
+ /** Whitelist the account config; no model-router headers are accepted here. */
47
+ export function normalizeAccountConfig(raw = {}) {
48
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new AccountValidationError();
49
+ const fields = {};
50
+ function text(key, fallback = '', limit = 256) {
51
+ const value = raw[key] ?? fallback;
52
+ if (typeof value !== 'string' || CONTROL.test(value) || value.length > limit) {
53
+ fields[key] = '请输入不含控制字符且长度合理的文本。';
54
+ return '';
55
+ }
56
+ return value.trim();
57
+ }
58
+ function flag(key) {
59
+ if (raw[key] !== undefined && typeof raw[key] !== 'boolean') fields[key] = '必须为开关值。';
60
+ return raw[key] === undefined ? true : raw[key] === true;
61
+ }
62
+ let siteUrl = text('siteUrl', '', 2048);
63
+ try {
64
+ const url = new URL(siteUrl);
65
+ 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();
66
+ siteUrl = url.href.replace(/\/+$/u, '');
67
+ } catch { fields.siteUrl = '请输入不含账号、密码、查询参数或片段的 HTTP(S) 站点地址。'; }
68
+ const userId = text('userId', '', 32);
69
+ if (!/^[1-9]\d*$/u.test(userId)) fields.userId = '用户 ID 必须为正整数字符串。';
70
+ const name = text('name', 'New API', 100);
71
+ if (!name) fields.name = '请输入账号名称。';
72
+ const authMode = text('authMode', 'token', 16);
73
+ if (!['token', 'cookie'].includes(authMode)) fields.authMode = '请选择令牌或 Cookie 认证。';
74
+ const accessToken = text('accessToken', '', 16384);
75
+ const cookie = text('cookie', '', 32768);
76
+ const quotaPerUnit = raw.quotaPerUnit ?? 500000;
77
+ if (typeof quotaPerUnit !== 'number' || !Number.isFinite(quotaPerUnit) || quotaPerUnit <= 0) fields.quotaPerUnit = '额度倍率必须为有限正数。';
78
+ const unit = text('unit', 'USD', 32);
79
+ if (!unit) fields.unit = '请输入单位标签。';
80
+ const enabled = flag('enabled');
81
+ const checkinEnabled = flag('checkinEnabled');
82
+ let schedule;
83
+ try { schedule = normalizeSchedule(raw.schedule); }
84
+ catch (error) {
85
+ if (!(error instanceof ScheduleValidationError)) throw error;
86
+ const messages = {
87
+ schedule: '请填写有效的自动签到设置。',
88
+ 'schedule.enabled': '自动签到必须为开关值。',
89
+ 'schedule.time': '请输入 HH:mm 格式的时间,范围为 00:00 至 23:59。',
90
+ 'schedule.timeZone': '请选择有效的时区,例如 Asia/Shanghai 或 UTC。',
91
+ };
92
+ for (const field of Object.keys(error.fields)) fields[field] = messages[field] ?? messages.schedule;
93
+ }
94
+ if (Object.keys(fields).length) throw new AccountValidationError('账号配置无效。', 400, fields);
95
+ return { name, enabled, siteUrl, userId, authMode, accessToken, cookie, quotaPerUnit, unit, checkinEnabled, schedule };
96
+ }
97
+
98
+ export function createNewApiClient({ fetchImpl = globalThis.fetch, now = () => new Date() } = {}) {
99
+ if (typeof fetchImpl !== 'function') throw new AccountValidationError('缺少网络请求实现。');
100
+ const timestamp = () => new Date(now()).toISOString();
101
+
102
+ async function request(account, endpoint, { method = 'GET', body } = {}) {
103
+ const url = new URL(`${account.siteUrl}${endpoint}`);
104
+ 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;
112
+ }
113
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
114
+ const controller = new AbortController();
115
+ let timer;
116
+ let reader;
117
+ const deadline = new Promise((_, reject) => {
118
+ timer = setTimeout(() => {
119
+ controller.abort();
120
+ void reader?.cancel().catch(() => {});
121
+ reject(new NewApiError('timeout'));
122
+ }, TIMEOUT_MS);
123
+ });
124
+ try {
125
+ return await Promise.race([deadline, (async () => {
126
+ 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); };
128
+ if (response.redirected || response.status >= 300 && response.status < 400 || response.url && new URL(response.url).origin !== url.origin) rejectResponse('redirect');
129
+ if (response.status === 401 || response.status === 403) rejectResponse('auth');
130
+ if (!response.ok) rejectResponse('http');
131
+ if (Number(response.headers.get('content-length')) > RESPONSE_LIMIT) rejectResponse('size');
132
+ if (!response.body?.getReader) throw new NewApiError('response');
133
+ reader = response.body.getReader();
134
+ const chunks = []; let size = 0;
135
+ while (true) {
136
+ const { done, value } = await reader.read();
137
+ if (done) break;
138
+ size += value.byteLength;
139
+ if (size > RESPONSE_LIMIT) { void reader.cancel().catch(() => {}); throw new NewApiError('size'); }
140
+ chunks.push(Buffer.from(value));
141
+ }
142
+ let result;
143
+ try { result = JSON.parse(Buffer.concat(chunks, size).toString('utf8')); }
144
+ catch { throw new NewApiError('response'); }
145
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw new NewApiError('response');
146
+ return result;
147
+ })()]);
148
+ } catch (error) {
149
+ if (error instanceof NewApiError) throw error;
150
+ throw new NewApiError(controller.signal.aborted ? 'timeout' : 'network');
151
+ } finally { clearTimeout(timer); }
152
+ }
153
+
154
+ async function balance(raw) {
155
+ const account = normalizeAccountConfig(raw);
156
+ const result = await request(account, '/api/user/self');
157
+ if (result.success !== true || !result.data || typeof result.data !== 'object') throw new NewApiError('response');
158
+ const data = result.data;
159
+ const id = typeof data.id === 'number' && Number.isSafeInteger(data.id) ? String(data.id) : data.id;
160
+ if (typeof id !== 'string' || id !== account.userId) throw new NewApiError('identity');
161
+ if (!Number.isSafeInteger(data.quota) || !Number.isSafeInteger(data.used_quota) || data.used_quota < 0 || !Number.isSafeInteger(data.request_count) || data.request_count < 0) throw new NewApiError('response');
162
+ return { at: timestamp(), quota: data.quota, usedQuota: data.used_quota, requestCount: data.request_count };
163
+ }
164
+
165
+ async function checkin(raw, { turnstileToken } = {}) {
166
+ const account = normalizeAccountConfig(raw);
167
+ if (!account.enabled || !account.checkinEnabled) throw new NewApiError('disabled');
168
+ if (turnstileToken !== undefined && (typeof turnstileToken !== 'string' || CONTROL.test(turnstileToken) || turnstileToken.length > 4096)) throw new AccountValidationError('验证码参数无效。');
169
+ await balance(account);
170
+ const site = await request(account, '/api/status');
171
+ if (site.success !== true || !site.data || typeof site.data.turnstile_check !== 'boolean') throw new NewApiError('response');
172
+ if (site.data.checkin_enabled === false) return { state: 'disabled', date: null, rewardQuota: null, checkedAt: null };
173
+ const current = await checkinStatus(account);
174
+ if (current.state !== 'unchecked') return current;
175
+ if (site.data.turnstile_check && !turnstileToken) return { state: 'requires_verification', date: null, rewardQuota: null, checkedAt: null };
176
+ const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : '';
177
+ let result;
178
+ try { result = await request(account, `/api/user/checkin${query}`, { method: 'POST', body: {} }); }
179
+ catch (error) {
180
+ if (error.code === 'auth') throw new NewApiError(account.authMode === 'token' ? 'cookie_required' : 'auth');
181
+ if (error.code === 'redirect') throw error;
182
+ // A transport/body/5xx failure can happen after the server committed the POST.
183
+ throw new NewApiError('ambiguous');
184
+ }
185
+ if (result.success === true) return claimed('success', result.data);
186
+ if (result.success === false) {
187
+ // Do not trust translated error text to mean already checked: confirm with GET.
188
+ const confirmed = await checkinStatus(account);
189
+ if (confirmed.state === 'already') return confirmed;
190
+ if (site.data.turnstile_check) return { state: 'requires_verification', date: null, rewardQuota: null, checkedAt: null };
191
+ throw new NewApiError(account.authMode === 'token' ? 'cookie_required' : 'checkin');
192
+ }
193
+ throw new NewApiError('ambiguous');
194
+ }
195
+
196
+ function validDate(value) {
197
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
198
+ }
199
+
200
+ function claimed(state, data = {}) {
201
+ const at = timestamp();
202
+ return { state, date: validDate(data?.checkin_date) ? data.checkin_date : at.slice(0, 10),
203
+ checkedAt: at, rewardQuota: Number.isSafeInteger(data?.quota_awarded) && data.quota_awarded >= 0 ? data.quota_awarded : null };
204
+ }
205
+
206
+ async function checkinStatus(account) {
207
+ const at = timestamp();
208
+ async function monthStatus(month) {
209
+ let result;
210
+ try { result = await request(account, `/api/user/checkin?month=${month}`); }
211
+ catch (error) { if (error.code === 'auth' && account.authMode === 'token') throw new NewApiError('cookie_required'); throw error; }
212
+ if (result.success !== true) throw new NewApiError(account.authMode === 'token' ? 'cookie_required' : 'checkin');
213
+ if (typeof result.data?.enabled !== 'boolean') throw new NewApiError('response');
214
+ if (result.data.enabled === false) return result.data;
215
+ if (typeof result.data.stats?.checked_in_today !== 'boolean' || !Array.isArray(result.data.stats.records)) throw new NewApiError('response');
216
+ return result.data;
217
+ }
218
+ const status = await monthStatus(at.slice(0, 7));
219
+ if (!status.enabled) return { state: 'disabled', date: null, checkedAt: null, rewardQuota: null };
220
+ if (!status.stats.checked_in_today) return { state: 'unchecked', date: null, checkedAt: null, rewardQuota: null };
221
+ // Site-local days can differ from UTC by a day. Fetch the adjacent month only
222
+ // at a UTC month boundary; the server's checked_in_today remains authoritative.
223
+ const nearbyDates = [-1, 0, 1].map(offset => new Date(Date.parse(at) + offset * 86400000).toISOString().slice(0, 10));
224
+ let records = status.stats.records;
225
+ for (const month of new Set(nearbyDates.map(date => date.slice(0, 7)))) {
226
+ if (month === at.slice(0, 7)) continue;
227
+ const adjacent = await monthStatus(month);
228
+ records = [...records, ...(adjacent.stats?.records ?? [])];
229
+ }
230
+ const todayRecord = records.filter(record => validDate(record?.checkin_date) && nearbyDates.includes(record.checkin_date)).sort((a, b) => b.checkin_date.localeCompare(a.checkin_date))[0];
231
+ // No returned calendar date: document UTC fallback, with unknown reward.
232
+ return claimed('already', todayRecord);
233
+ }
234
+
235
+ // The manager must not short-circuit this client's authoritative day check.
236
+ return { balance, checkin, authoritativeCheckinDay: true };
237
+ }
@@ -0,0 +1,48 @@
1
+ import { isAbsolute } from 'node:path'
2
+ import { compileParameters, copyJSON, isObject } from '../lib/parameters.mjs'
3
+ import { createDefaultProfile } from './profile.mjs'
4
+
5
+ const blocked = new Set(['authorization', 'x-api-key', 'api-key', 'x-goog-api-key', 'cookie', 'host', 'content-length', 'content-encoding', 'connection', 'transfer-encoding', 'proxy-authorization', 'proxy-connection', 'keep-alive', 'upgrade', 'te', 'trailer'])
6
+
7
+ export function pluginOptions(raw = {}) {
8
+ if (!isObject(raw)) throw new TypeError('Plugin options must be an object')
9
+ for (const key of Object.keys(raw)) {
10
+ if (!['profileFile', 'accountBindings', 'baseURL', 'userAgent', 'headers', 'models', 'parameters', 'searches'].includes(key)) throw new TypeError(`Unknown plugin option: ${key}`)
11
+ }
12
+ if (raw.profileFile !== undefined && (typeof raw.profileFile !== 'string' || !isAbsolute(raw.profileFile))) throw new TypeError('profileFile must be an absolute path')
13
+ if (raw.baseURL !== undefined) {
14
+ let url
15
+ try { url = new URL(raw.baseURL) } catch { throw new TypeError('baseURL must be an absolute HTTP(S) URL') }
16
+ if (typeof raw.baseURL !== 'string' || !['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash
17
+ || (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new TypeError('Invalid baseURL: use HTTPS or loopback HTTP without credentials, query or fragment')
18
+ }
19
+ if (raw.userAgent !== undefined && (typeof raw.userAgent !== 'string' || !raw.userAgent.trim() || /[^\x20-\x7e]/.test(raw.userAgent))) throw new TypeError('userAgent must be a nonempty printable ASCII value')
20
+ if (raw.headers !== undefined) {
21
+ if (!isObject(raw.headers)) throw new TypeError('headers must be an object')
22
+ const seen = new Set()
23
+ for (const [key, value] of Object.entries(raw.headers)) {
24
+ const name = key.toLowerCase()
25
+ if (blocked.has(name) || name.startsWith('x-magpie-') || name === 'user-agent') throw new TypeError(`Cannot configure header ${name}; use userAgent for User-Agent`)
26
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name) || (value !== null && (typeof value !== 'string' || /[^\x20-\x7e\t]/.test(value)))) throw new TypeError(`Invalid header option: ${name}`)
27
+ if (seen.has(name)) throw new TypeError(`Duplicate header option: ${name}`)
28
+ seen.add(name)
29
+ }
30
+ }
31
+ if (raw.models !== undefined && (!isObject(raw.models) || !Object.keys(raw.models).length || Object.values(raw.models).some(value => !isObject(value)))) throw new TypeError('models must be a nonempty object of model definitions')
32
+ if (raw.parameters !== undefined) compileParameters(raw.parameters)
33
+ if (raw.searches !== undefined && typeof raw.searches !== 'boolean') throw new TypeError('searches must be a boolean')
34
+ const bindings = raw.accountBindings ?? {}
35
+ if (!isObject(bindings) || Object.entries(bindings).some(([key, value]) => !key || typeof value !== 'string' || !value.trim())) throw new TypeError('accountBindings must map host account keys to site account IDs')
36
+ return { ...copyJSON(raw), accountBindings: { ...bindings } }
37
+ }
38
+
39
+ // Saved profiles stay target-scoped; old plugin options are explicit global defaults.
40
+ export function optionProfile(options) {
41
+ const profile = createDefaultProfile()
42
+ if (options.baseURL) profile.upstream.baseUrl = options.baseURL.replace(/\/+$/, '')
43
+ if (options.userAgent !== undefined) profile.headers.userAgent = options.userAgent
44
+ if (options.headers) profile.headers.extra = Object.entries(options.headers).map(([name, value]) => ({ name, value, enabled: true }))
45
+ if (options.models) profile.models = Object.keys(options.models)
46
+ if (options.parameters) profile.parameters = copyJSON(options.parameters)
47
+ return profile
48
+ }