opencode-agentrouter-auth 0.0.0-stage → 0.1.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/index.mjs ADDED
@@ -0,0 +1,1122 @@
1
+ import { explainResponse } from "./response.mjs";
2
+ import { prepareRequest } from "./request.mjs";
3
+ import { loadTextFilter } from "./content-filter.mjs";
4
+ import { parseGithubCookie } from "./github-cookie.mjs";
5
+
6
+ // AgentRouter (agentrouter.org) for magpie / OpenCode.
7
+ //
8
+ // Three things make this vendor different from a plain OpenAI-compatible
9
+ // relay:
10
+ //
11
+ // 1. Its gateway only answers a client it recognises. A request whose
12
+ // User-Agent is curl's, or a browser's, or anything else off its list is
13
+ // refused with "unauthorized client detected", even when the key is good.
14
+ // So the request headers are not cosmetic here: they are what gets the key
15
+ // through, and the one that works is the vendor's own client's. They are
16
+ // configurable — per provider and per account — and the default is that
17
+ // client's. The provider's own switch turns that default off, for a user
18
+ // who would rather send exactly the headers they named.
19
+ //
20
+ // 2. It has no daily check-in to call. The site is a New API deployment,
21
+ // and most New API sites put one on GET /api/user/checkin. This one
22
+ // does not: that path falls through to New API's *admin* route
23
+ // /api/user/:id, which answers "权限不足" to every ordinary account —
24
+ // the same answer it gives /api/user/foo or /api/user/zzz. The daily
25
+ // credit is granted at sign-in instead (the site's login response
26
+ // carries checked_in, and its settings keep DailyCheckinQuota), so
27
+ // there is nothing for a plugin to claim after the fact. The check-in
28
+ // hook can renew GitHub OAuth using an explicitly supplied GitHub session.
29
+ // Legacy password login remains available only for sites supporting it.
30
+ //
31
+ // 3. /api/user/self reports quota and used_quota, but often returns an
32
+ // Aliyun slider-captcha page instead. Try it with an access token or
33
+ // browser session; neither credential guarantees passage through WAF.
34
+ // A password sign-in can also return the current user record and grant
35
+ // the day's credit. GitHub accounts may have no site password.
36
+ //
37
+ // Without a password, what is left is the spend: /api/log/self/stat
38
+ // answers a session or an access token with the account's used quota,
39
+ // and /v1/dashboard/billing/usage answers the key with the same figure
40
+ // in cents. Neither carries the credit, so those accounts report what
41
+ // they spent rather than a balance — an invented one would be worse.
42
+ //
43
+ // Signing in with only the key is allowed. It can read consumption, but
44
+ // cannot determine remaining credit or renew a GitHub login for check-in.
45
+
46
+ const PROVIDER = "agentrouter";
47
+
48
+ // The request endpoint: where completions and the model list go. This is the
49
+ // one the user picked, and it stays what they picked even when the site the
50
+ // check-in talks to is the other front door. A site's own API lives under it.
51
+ const ENDPOINT = "https://agentrouter.org";
52
+
53
+ const SITES = {
54
+ // The vendor runs two front doors for the same accounts. `api` is the
55
+ // request endpoint a site serves; `site` is where its web routes are.
56
+ org: { name: "AgentRouter", api: "https://agentrouter.org", site: "https://agentrouter.org" },
57
+ air: { name: "AgentRouter (ps.air-outer.com)", api: "https://ps.air-outer.com", site: "https://ps.air-outer.com" },
58
+ };
59
+
60
+ // What the vendor's own clients send. codex_cli_rs is what its gateway
61
+ // admits; the rest are the headers a Codex CLI sends beside it.
62
+ const DEFAULT_HEADERS = {
63
+ "User-Agent": "codex_cli_rs/0.101.0",
64
+ Accept: "application/json",
65
+ };
66
+
67
+ // Models are listed by the vendor, so this is only what to show before a
68
+ // sign-in and the shape (limits, tool calls) each listed model takes.
69
+ const MODEL_SHAPE = {
70
+ name: undefined,
71
+ limit: { context: 300000, output: 128000 },
72
+ tool_call: true,
73
+ reasoning: true,
74
+ };
75
+
76
+ // The models this plugin itself ships, by the name to show them under. They
77
+ // are what the row lists before the vendor's own list arrives — and what it
78
+ // falls back to when that list cannot be read.
79
+ //
80
+ // magpie also keeps a models.dev catalog, and its cached entry for this
81
+ // vendor still carries models the site has retired (see README, "模型列表从
82
+ // 哪来"). Falling back to the row's current models would re-advertise them,
83
+ // so the fallback is built from this table instead: a model shown here is one
84
+ // this plugin vouches for.
85
+ const DECLARED_MODELS = {
86
+ "gpt-5.6-sol": "GPT-5.6 Sol",
87
+ "gpt-6-astra": "GPT-6 Astra",
88
+ "gpt-6.1-sol": "GPT-6.1 Sol",
89
+ };
90
+
91
+ // codexHeaders is whether requests are sent as a Codex CLI's, as the
92
+ // provider's own switch has it. The switch is the vendor's whitelist made
93
+ // optional: off, only the headers the user gave are sent. It is read from the
94
+ // host when the plugin loads (magpie hands the reader over as input.magpie),
95
+ // and defaults to on — including on a host too old to have the switch, which
96
+ // is why the option is checked for rather than assumed.
97
+ let codexHeaders = () => true;
98
+
99
+ const REQUEST_TIMEOUT = 30_000;
100
+ const CHECKIN_TIMEOUT = 25_000;
101
+
102
+ const siteOf = (id) => SITES[id] ?? SITES.org;
103
+
104
+ function withTimeout(ms) {
105
+ return AbortSignal.timeout(ms);
106
+ }
107
+
108
+ // parseHeaders turns the one-header-per-line text an account keeps into an
109
+ // object. A line is "Name: value"; blank lines and lines starting with #
110
+ // are skipped, so a block copied out of a browser's devtools works as it
111
+ // is. A name repeated keeps its last value, as a Headers object would.
112
+ function parseHeaders(text) {
113
+ const out = {};
114
+ for (const line of String(text ?? "").split(/\r?\n/)) {
115
+ const s = line.trim();
116
+ if (!s || s.startsWith("#")) continue;
117
+ const at = s.indexOf(":");
118
+ if (at < 1) continue;
119
+ const name = s.slice(0, at).trim();
120
+ const value = s.slice(at + 1).trim();
121
+ if (name && value) out[name] = value;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ // modelHeaders is the header block the provider's editor keeps on a model:
127
+ // info() merges each model's `headers` from the plugin's config, and magpie's
128
+ // provider editor writes the user's own there. It is the place a header can
129
+ // be changed after sign-in without signing in again, so it is read here as
130
+ // the provider's own block.
131
+ function modelHeaders(provider) {
132
+ const models = provider?.models;
133
+ if (!models || typeof models !== "object") return {};
134
+ const out = {};
135
+ for (const m of Object.values(models)) {
136
+ if (m && typeof m.headers === "object" && m.headers) Object.assign(out, m.headers);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // headersOf is what a request to the vendor is sent with, lowest to highest:
142
+ // the defaults, the provider's own block (its models' headers, which the
143
+ // editor keeps), and the account's own, each overriding the one before it.
144
+ //
145
+ // The Codex User-Agent is the switch's business; Accept is not a client
146
+ // claim, so it stays whatever the switch says.
147
+ function headersOf(provider, auth) {
148
+ return {
149
+ ...(codexHeaders() ? { "User-Agent": DEFAULT_HEADERS["User-Agent"] } : {}),
150
+ Accept: DEFAULT_HEADERS.Accept,
151
+ ...modelHeaders(provider),
152
+ ...parseHeaders(provider?.headers),
153
+ ...parseHeaders(headersText(auth)),
154
+ };
155
+ }
156
+
157
+ // applyHeaders sets headers on a Headers object, dropping any whose value is
158
+ // empty so a user can take one off by clearing it.
159
+ function applyHeaders(h, extra) {
160
+ for (const [k, v] of Object.entries(extra)) {
161
+ if (v === undefined || v === null || v === "") h.delete(k);
162
+ else h.set(k, String(v));
163
+ }
164
+ return h;
165
+ }
166
+
167
+ // isURLish says whether a value can be a base URL at all: an http(s) address.
168
+ // The endpoint question is free text, so a key pasted into it — which is what
169
+ // the account manager's first question invites, since it is where the key
170
+ // used to go — lands in the same field as a real address does. Such a value
171
+ // is not an endpoint however it was stored, and building a base out of it
172
+ // sends every request to nothing at all, with no error to show for it.
173
+ function isURLish(v) {
174
+ return /^https?:\/\/\S+$/i.test(String(v ?? "").trim());
175
+ }
176
+
177
+ // apiBase is the base completions go to. It follows the account's own
178
+ // endpoint when it gave one, and otherwise the endpoint this plugin was built
179
+ // for — never the check-in site, so picking ps.air-outer.com for the check-in
180
+ // doesn't quietly move requests off agentrouter.org.
181
+ //
182
+ // A stored endpoint that isn't a URL is ignored rather than used: the
183
+ // request then still goes to the vendor, which is what the account wanted
184
+ // when it pasted a key there.
185
+ function apiBase(provider, auth) {
186
+ const said = String(meta(auth, "endpoint") ?? provider?.endpoint ?? "").trim();
187
+ if (isURLish(said)) return `${said.replace(/\/+$/, "")}/v1`;
188
+ const base = String(meta(auth, "apiBase") ?? "").trim();
189
+ if (base) return base.replace(/\/+$/, "");
190
+ return `${ENDPOINT}/v1`;
191
+ }
192
+
193
+ // siteBase is where the site's own web routes are: the balance and the
194
+ // check-in. It follows the site the account signed in to, not the request
195
+ // endpoint, since a front door's web routes are its own.
196
+ function siteBase(provider, auth) {
197
+ const said = String(meta(auth, "siteURL") ?? provider?.siteURL ?? "").trim();
198
+ if (said) return said.replace(/\/+$/, "");
199
+ return siteOf(siteName(auth) || provider?.site).site;
200
+ }
201
+
202
+ // meta reads a field the account keeps, whether magpie hands the auth as the
203
+ // flat record ({type, key, accessToken, …}) or with the sign-in's answers
204
+ // under metadata ({type, key, metadata: {accessToken, …}}). Both shapes reach
205
+ // a hook: the flat one from the store, the nested one from what authorize
206
+ // returned, so every reader goes through here.
207
+ function meta(auth, name) {
208
+ if (!auth) return undefined;
209
+ const flat = auth[name];
210
+ if (flat !== undefined && flat !== null && String(flat).trim() !== "") return flat;
211
+ const nested = auth.metadata?.[name];
212
+ return nested === undefined || nested === null ? undefined : nested;
213
+ }
214
+
215
+ // userToken is the credential the site's own /api/user routes take: the
216
+ // access token from the profile page. An sk- key is not one of those, and
217
+ // sending it there is what makes the balance read as "access token 无效".
218
+ function userToken(auth) {
219
+ return String(meta(auth, "accessToken") ?? "").trim();
220
+ }
221
+
222
+ // apiKey is the credential completions take.
223
+ function apiKey(auth) {
224
+ return String(auth?.key ?? "").trim();
225
+ }
226
+
227
+ // userId is the New-Api-User header's value. A New API site answers 401 to
228
+ // a session that is good but doesn't say which user it is, so the account
229
+ // keeps the id beside the token.
230
+ function userId(auth) {
231
+ return String(meta(auth, "userId") ?? "").trim();
232
+ }
233
+
234
+ // headersText is the account's own header block, if it gave one.
235
+ function headersText(auth) {
236
+ return String(meta(auth, "headers") ?? "");
237
+ }
238
+
239
+ // cookieText is the browser cookie the account copied from the site.
240
+ // A valid session authenticates user reads. WAF can still challenge those
241
+ // reads even when the same Cookie works in a browser.
242
+ function cookieText(auth) {
243
+ return String(meta(auth, "cookie") ?? "").trim();
244
+ }
245
+
246
+ // parseCookies turns the text a browser shows for a site ("a=1; b=2", or
247
+ // the tab-separated lines of a devtools cookie table) into a Cookie header.
248
+ // A name repeated keeps its last value, as the browser's own send would.
249
+ function parseCookies(text) {
250
+ const out = [];
251
+ const seen = new Map();
252
+ for (const raw of String(text ?? "").split(/\r?\n|;/)) {
253
+ const s = raw.trim();
254
+ if (!s || s.startsWith("#")) continue;
255
+ // A devtools row is "name<TAB>value<TAB>domain…"; a plain pair is
256
+ // "name=value". Both keep only the first two fields.
257
+ const cols = s.split("\t");
258
+ let name, value;
259
+ if (cols.length > 1) {
260
+ name = cols[0].trim();
261
+ value = (cols[1] ?? "").trim();
262
+ } else {
263
+ const at = s.indexOf("=");
264
+ if (at < 1) continue;
265
+ name = s.slice(0, at).trim();
266
+ value = s.slice(at + 1).trim();
267
+ }
268
+ if (!name) continue;
269
+ if (seen.has(name)) out[seen.get(name)] = `${name}=${value}`;
270
+ else {
271
+ seen.set(name, out.length);
272
+ out.push(`${name}=${value}`);
273
+ }
274
+ }
275
+ return out.join("; ");
276
+ }
277
+
278
+ // siteName is the site the account signed in to.
279
+ function siteName(auth) {
280
+ return String(meta(auth, "site") ?? "org");
281
+ }
282
+
283
+ // userHeaders are what a /api/user call carries: the access token, the user
284
+ // id when the account knows it, and the browser cookie that gets past the
285
+ // WAF when the account has one.
286
+ function userHeaders(provider, auth) {
287
+ const h = headersOf(provider, auth);
288
+ h.Authorization = userToken(auth);
289
+ delete h["Content-Type"];
290
+ if (userId(auth)) h["New-Api-User"] = userId(auth);
291
+ const cookie = parseCookies(cookieText(auth));
292
+ if (cookie) h.Cookie = cookie;
293
+ return h;
294
+ }
295
+
296
+ // masked keeps a value out of a message: a key or a token never goes into
297
+ // an error the user sees or a log line.
298
+ function masked(v) {
299
+ const s = String(v ?? "");
300
+ if (s.length <= 8) return s ? "…" : "";
301
+ return `${s.slice(0, 4)}…${s.slice(-4)}`;
302
+ }
303
+
304
+ async function jsonOrText(res) {
305
+ const text = await res.text();
306
+ try {
307
+ return { json: JSON.parse(text), text };
308
+ } catch {
309
+ return { json: undefined, text };
310
+ }
311
+ }
312
+
313
+ // ---- signing in with GitHub ---------------------------------------------------
314
+
315
+ // The site's own callback page (/oauth/github) is a route in its single-page
316
+ // app, and that route redeems the code the instant it loads — so by the time
317
+ // the user has copied the address out of the browser, the code is already
318
+ // spent and the exchange here fails with "code is incorrect or expired".
319
+ //
320
+ // A path *under* that one is not a route, so the app shows its "page not
321
+ // found" screen and leaves the code alone, which is what this needs. GitHub
322
+ // matches a callback against a subdirectory of the registered URL, and the
323
+ // site's own token exchange does not send redirect_uri at all, so the URL
324
+ // used here only has to survive the authorize step.
325
+ const GITHUB_CALLBACK_PATH = "/oauth/github/magpie";
326
+
327
+ // Login accepts only a full callback URL belonging to this flow.
328
+ function githubCallbackCode(pasted, expectedURL, state) {
329
+ const text = String(pasted ?? "").trim();
330
+ if (!text || text.length > 8192) return "";
331
+ try {
332
+ const url = new URL(text);
333
+ const expected = new URL(expectedURL);
334
+ const params = url.searchParams;
335
+ if (url.origin !== expected.origin || url.pathname !== expected.pathname ||
336
+ url.username || url.password || url.hash || params.has("error") ||
337
+ params.getAll("state").length !== 1 || params.get("state") !== state ||
338
+ params.getAll("code").length !== 1) return "";
339
+ const code = params.get("code");
340
+ return /^[A-Za-z0-9_-]{8,2048}$/.test(code ?? "") ? code : "";
341
+ } catch {
342
+ return "";
343
+ }
344
+ }
345
+
346
+ // callbackUrl is where GitHub is asked to send the browser. It has to sit on
347
+ // the origin the site's app registered — the site's own web routes, which the
348
+ // site reports — because GitHub checks the callback's host, and the code is
349
+ // checked against the site's app rather than against anything here.
350
+ const callbackUrl = (origin) => `${origin}${GITHUB_CALLBACK_PATH}`;
351
+
352
+ // oauthState asks the site for the state its GitHub sign-in expects. The site
353
+ // issues it and later checks it on the callback, so it can't be made up here.
354
+ async function oauthState(base) {
355
+ try {
356
+ const res = await fetch(`${base}/api/oauth/state?mode=login`, {
357
+ headers: DEFAULT_HEADERS,
358
+ signal: withTimeout(REQUEST_TIMEOUT),
359
+ });
360
+ const { json } = await jsonOrText(res);
361
+ const st = typeof json?.data === "string" ? json.data.trim() : "";
362
+ if (!res.ok || json?.success !== true || !st) return { ok: false, why: "站点没有下发 OAuth state" };
363
+ return { ok: true, state: st };
364
+ } catch (e) {
365
+ return { ok: false, why: `拿不到 OAuth state:${e?.message ?? String(e)}` };
366
+ }
367
+ }
368
+
369
+ // siteAuthConfig reads the facts the authorize URL needs out of the site's own
370
+ // status: the GitHub app whose callback the site owns, and the origin that
371
+ // callback lives on. The origin is not simply the front door the user picked —
372
+ // the vendor runs two (org and air) for the same accounts, and GitHub matches
373
+ // the callback against the app's registered host, which only the site's own
374
+ // `server_address` names.
375
+ async function siteAuthConfig(base) {
376
+ try {
377
+ const res = await fetch(`${base}/api/status`, {
378
+ headers: DEFAULT_HEADERS,
379
+ signal: withTimeout(REQUEST_TIMEOUT),
380
+ });
381
+ const { json } = await jsonOrText(res);
382
+ const id = typeof json?.data?.github_client_id === "string" ? json.data.github_client_id.trim() : "";
383
+ if (!res.ok || !id) return { ok: false, why: "站点没有开启 GitHub 登录" };
384
+ const reported = json.data.server_address;
385
+ if (reported != null && typeof reported !== "string") return { ok: false, why: "站点回调地址无效" };
386
+ const url = new URL(String(reported ?? "").trim() || base);
387
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
388
+ !Object.values(SITES).some(site => site.site === url.origin)) {
389
+ return { ok: false, why: "站点回调地址不受信任" };
390
+ }
391
+ return { ok: true, id, origin: url.origin };
392
+ } catch (e) {
393
+ return { ok: false, why: `读不到站点配置:${e?.message ?? String(e)}` };
394
+ }
395
+ }
396
+
397
+ // codeOf pulls the code out of what the user pasted: the whole callback URL
398
+ // (…/oauth/github?code=abc&state=…), or the code alone.
399
+ function codeOf(pasted) {
400
+ const s = String(pasted ?? "").trim();
401
+ if (!s) return "";
402
+ const m = /[?&]code=([^&#\s]+)/.exec(s);
403
+ if (m) {
404
+ try {
405
+ return decodeURIComponent(m[1]);
406
+ } catch {
407
+ return m[1];
408
+ }
409
+ }
410
+ // A bare code has no URL around it; anything with spaces or a scheme is
411
+ // not one.
412
+ if (/^[A-Za-z0-9_-]{8,}$/.test(s)) return s;
413
+ return "";
414
+ }
415
+
416
+ // sessionOf pulls the site's session cookie out of a response. This site
417
+ // answers a sign-in by setting `session` rather than by putting a token in
418
+ // the body, and `get("set-cookie")` joins every cookie with ", " — a
419
+ // character that also appears inside one (an Expires date) — so the separate
420
+ // entries are what get read when the runtime offers them.
421
+ function sessionOf(res) {
422
+ let lines = [];
423
+ try {
424
+ if (typeof res.headers.getSetCookie === "function") lines = res.headers.getSetCookie() ?? [];
425
+ } catch {
426
+ lines = [];
427
+ }
428
+ if (!lines.length) {
429
+ const joined = res.headers.get("set-cookie");
430
+ if (joined) lines = [joined];
431
+ }
432
+ for (const line of lines) {
433
+ const m = /(?:^|[;,])\s*session=([^;,\s]+)/.exec(String(line));
434
+ if (m) return m[1];
435
+ }
436
+ return "";
437
+ }
438
+
439
+ // redeemGithub trades the code for the site's sign-in. The answer is the user
440
+ // record: `quota` is the balance and `checked_in` says whether this sign-in
441
+ // granted the day's credit, and the same response carries the session the
442
+ // /api/user routes take. The account keeps this session alongside the
443
+ // system access token explicitly entered before authorization.
444
+ async function redeemGithub(base, code, state, inputs) {
445
+ const token = String(inputs.accessToken ?? "").trim();
446
+ if (!token) return { type: "failed", error: "缺少系统访问令牌,请重新登录或改用 API Key 接入。" };
447
+ const u = new URL(base + "/api/oauth/github");
448
+ u.searchParams.set("code", code);
449
+ u.searchParams.set("state", state);
450
+ u.searchParams.set("mode", "login");
451
+ const res = await fetch(u, { headers: DEFAULT_HEADERS, redirect: "manual", signal: withTimeout(REQUEST_TIMEOUT) });
452
+ const session = sessionOf(res);
453
+ const { json } = await jsonOrText(res);
454
+ const d = json?.data;
455
+ if (!res.ok || json?.success !== true || !d?.id) {
456
+ return { type: "failed", error: "GitHub 授权兑换未完成,授权码可能已使用或过期;请重新授权或改用 API Key 接入。" };
457
+ }
458
+ const found = await keyOf(base, token, String(d.id));
459
+ if (found.kind !== "found") {
460
+ const messages = {
461
+ missing: "未找到启用且完整的模型 Key",
462
+ ambiguous: "存在多个不同的模型 Key,无法确定使用哪一个",
463
+ incomplete: "模型 Key 列表可能不完整,无法确定唯一候选",
464
+ rejected: "站点拒绝读取模型 Key,请检查系统访问令牌及权限",
465
+ blocked: "读取模型 Key 时遇到站点验证页",
466
+ unsupported: "站点返回的模型 Key 列表格式无法确认",
467
+ upstream: "站点暂时无法提供模型 Key 列表",
468
+ network: "读取模型 Key 的网络请求失败或超时",
469
+ };
470
+ return { type: "failed", error: messages[found.kind] + "。请改用 API Key 接入,或确认后主动重试。" };
471
+ }
472
+ return {
473
+ type: "success",
474
+ key: found.key,
475
+ metadata: {
476
+ site: "org",
477
+ accessToken: token,
478
+ userId: String(d.id),
479
+ ...(session ? { cookie: "session=" + session } : {}),
480
+ },
481
+ };
482
+ }
483
+
484
+ function usableModelKey(value) {
485
+ if (typeof value !== "string") return false;
486
+ const key = value.trim();
487
+ return key !== "" && !["agentrouter", "your_api_key"].includes(key.toLowerCase()) && !/[*…<>]|\.\.\.|\s/.test(key);
488
+ }
489
+
490
+ function selectModelKey(json, pageSize) {
491
+ const data = json?.data;
492
+ const items = Array.isArray(data) ? data : data?.items;
493
+ if (!Array.isArray(items)) return { kind: "unsupported" };
494
+ const total = Array.isArray(data) ? json.total : data?.total;
495
+ if (total != null && (!Number.isSafeInteger(total) || total < items.length)) return { kind: "unsupported" };
496
+ if (total != null ? total > items.length : items.length >= pageSize) return { kind: "incomplete" };
497
+ const keys = new Set();
498
+ const now = Date.now() / 1000;
499
+ for (const item of items) {
500
+ if (item?.status !== 1 || !usableModelKey(item.key)) continue;
501
+ const expiry = item.expired_time;
502
+ if (expiry != null && (typeof expiry !== "number" || !Number.isFinite(expiry) || (expiry !== -1 && expiry <= now))) continue;
503
+ keys.add(item.key.trim());
504
+ }
505
+ if (keys.size === 0) return { kind: "missing" };
506
+ if (keys.size !== 1) return { kind: "ambiguous" };
507
+ return { kind: "found", key: keys.values().next().value };
508
+ }
509
+
510
+ async function keyOf(base, token, id) {
511
+ const pageSize = 10;
512
+ try {
513
+ const res = await fetch(base + "/api/token/?p=1&page_size=" + pageSize, {
514
+ headers: { ...DEFAULT_HEADERS, Authorization: token, "New-Api-User": id },
515
+ signal: withTimeout(REQUEST_TIMEOUT),
516
+ });
517
+ const { json, text } = await jsonOrText(res);
518
+ if (!json && /^\s*</.test(text)) return { kind: "blocked" };
519
+ if (res.status === 401 || res.status === 403) return { kind: "rejected" };
520
+ if (!res.ok) return { kind: "upstream" };
521
+ if (json?.success === false) return { kind: "rejected" };
522
+ return selectModelKey(json, pageSize);
523
+ } catch {
524
+ return { kind: "network" };
525
+ }
526
+ }
527
+
528
+ // signIn is one POST to the site's login route, and it is the whole feature:
529
+ // the login response carries both the day's check-in and the balance.
530
+ //
531
+ // The vendor grants its daily credit at sign-in rather than through a route
532
+ // of its own — its front end says "签到成功,新增额度已到账" when the login
533
+ // response has checked_in — and that same response carries quota and
534
+ // used_quota, the account's balance. So one call does both jobs, and it is
535
+ // also the only way to read the balance from a server: /api/user/self, the
536
+ // route New API normally reads it from, is the one this site's WAF answers
537
+ // with a slider captcha, while /api/user/login is left alone.
538
+ //
539
+ // It answers {ok, checkedIn?, balance?, why?}. The caller decides whether a
540
+ // false `ok` is the account's to fix (wrong password) or nobody's (the site
541
+ // is down), so the reasons stay distinguishable here.
542
+ async function signIn(provider, auth) {
543
+ const user = String(meta(auth, "username") ?? "").trim();
544
+ const pass = String(meta(auth, "password") ?? "");
545
+ if (!user || !pass) return { ok: false, why: "no-password" };
546
+ const h = headersOf(provider, auth);
547
+ h["Content-Type"] = "application/json";
548
+ delete h.Authorization;
549
+ let res;
550
+ try {
551
+ res = await fetch(`${siteBase(provider, auth)}/api/user/login`, {
552
+ method: "POST",
553
+ headers: h,
554
+ body: JSON.stringify({ username: user, password: pass }),
555
+ signal: withTimeout(REQUEST_TIMEOUT),
556
+ });
557
+ } catch (e) {
558
+ return { ok: false, why: `unreachable: ${e?.message ?? String(e)}` };
559
+ }
560
+ const { json, text } = await jsonOrText(res);
561
+ if (!json) {
562
+ if (/^\s*<(?:!doctype|html)/i.test(String(text))) return { ok: false, why: "blocked", blocked: true };
563
+ return { ok: false, why: `HTTP ${res.status}` };
564
+ }
565
+ if (json.success === false) {
566
+ const msg = String(json.message ?? json.msg ?? "");
567
+ // The site words a wrong password and a banned account the same way, so
568
+ // this is "the site refused these credentials", which is the account's
569
+ // to fix.
570
+ if (/密码|password|封禁|banned|错误/i.test(msg)) return { ok: false, why: "bad-password", message: msg };
571
+ return { ok: false, why: msg || "refused", message: msg };
572
+ }
573
+ const d = json.data ?? {};
574
+ const quota = quotaNumber(d.quota);
575
+ const used = quotaNumber(d.used_quota);
576
+ const balance =
577
+ Number.isFinite(quota) && Number.isFinite(used)
578
+ ? { quota, used, name: d.username ?? d.display_name ?? "" }
579
+ : undefined;
580
+ if (balance) balance.total = balance.quota + balance.used;
581
+ // `checked_in: true` means *this* sign-in granted the day's credit. A false
582
+ // means no new grant; a missing/non-boolean marker proves nothing.
583
+ return { ok: true, checkedIn: d.checked_in, balance, name: d.username ?? d.display_name ?? "" };
584
+ }
585
+
586
+ // Try the read-only user endpoint first, then password sign-in. Preserve
587
+ // the failure reason when neither returns a complete quota record.
588
+ async function balance(provider, auth) {
589
+ const viaToken = await balanceByToken(provider, auth);
590
+ if (viaToken.ok) return viaToken;
591
+ const viaLogin = await signIn(provider, auth);
592
+ if (viaLogin.ok && viaLogin.balance) return { ok: true, balance: viaLogin.balance };
593
+ if (viaLogin.ok) return { ok: false, why: "no-quota" };
594
+ // Report the reason the user can act on: a wrong password outranks a
595
+ // captcha, and a missing password means the token route's failure is the
596
+ // real one.
597
+ if (viaLogin.why === "bad-password") return { ok: false, why: "expired", expired: true, message: viaLogin.message };
598
+ if (viaLogin.why !== "no-password") return viaLogin;
599
+ return viaToken;
600
+ }
601
+
602
+ // Read /api/user/self with either an access token or a browser session.
603
+ async function balanceByToken(provider, auth) {
604
+ if (!userToken(auth) && !parseCookies(cookieText(auth))) return { ok: false, why: "no-token" };
605
+ try {
606
+ const res = await fetch(`${siteBase(provider, auth)}/api/user/self`, {
607
+ headers: userHeaders(provider, auth),
608
+ signal: withTimeout(REQUEST_TIMEOUT),
609
+ });
610
+ const { json, text } = await jsonOrText(res);
611
+ // New API answers 200 with success:false for a token it refuses, so the
612
+ // status alone doesn't say; the body does.
613
+ if (json && json.success === false) {
614
+ const msg = String(json.message ?? "");
615
+ if (/access\s*token|token.*(无效|invalid)|未登录/i.test(msg)) return { ok: false, why: "expired", expired: true };
616
+ return { ok: false, why: msg || "refused" };
617
+ }
618
+ if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
619
+ // The site sits behind a WAF that answers an HTML slider-captcha page
620
+ // with 200 instead of the JSON: saying "<!doctype html>" back to the
621
+ // user hides the real problem, which is that the read was blocked and a
622
+ // sign-in is what gets past it.
623
+ if (!json) {
624
+ if (/^\s*<(?:!doctype|html)/i.test(String(text))) {
625
+ return { ok: false, why: "blocked", blocked: true };
626
+ }
627
+ return { ok: false, why: String(text).slice(0, 80) };
628
+ }
629
+ const d = json.data ?? {};
630
+ const quota = quotaNumber(d.quota);
631
+ const used = quotaNumber(d.used_quota);
632
+ if (!Number.isFinite(quota) || !Number.isFinite(used)) return { ok: false, why: "no-quota" };
633
+ return { ok: true, balance: { quota, used, total: quota + used, name: d.username ?? d.display_name ?? "" } };
634
+ } catch (e) {
635
+ return { ok: false, why: `unreachable: ${e?.message ?? String(e)}` };
636
+ }
637
+ }
638
+
639
+ // These endpoints report cumulative consumption, never remaining credit.
640
+ async function spendOf(provider, auth) {
641
+ const rate = quotaRate(provider);
642
+ const reads = [];
643
+ if (userToken(auth) || parseCookies(cookieText(auth))) {
644
+ reads.push({
645
+ url: `${siteBase(provider, auth)}/api/log/self/stat`,
646
+ headers: userHeaders(provider, auth),
647
+ field: "quota",
648
+ divisor: rate,
649
+ });
650
+ }
651
+ if (apiKey(auth)) {
652
+ reads.push({
653
+ url: `${apiBase(provider, auth)}/dashboard/billing/usage`,
654
+ headers: { ...headersOf(provider, auth), Authorization: `Bearer ${apiKey(auth)}` },
655
+ field: "total_usage",
656
+ divisor: 100,
657
+ });
658
+ }
659
+ for (const read of reads) {
660
+ try {
661
+ const res = await fetch(read.url, { headers: read.headers, signal: withTimeout(REQUEST_TIMEOUT) });
662
+ const { json } = await jsonOrText(res);
663
+ if (!res.ok || !json || json.success === false) continue;
664
+ const value = (json.data ?? json)[read.field];
665
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) continue;
666
+ return { ok: true, spent: value / read.divisor };
667
+ } catch {
668
+ // A failed user read must not prevent the independent key fallback.
669
+ }
670
+ }
671
+ return { ok: false };
672
+ }
673
+
674
+ function quotaNumber(value) {
675
+ if (typeof value !== "number" && !(typeof value === "string" && value.trim())) return NaN;
676
+ const n = Number(value);
677
+ return Number.isFinite(n) ? n : NaN;
678
+ }
679
+
680
+ function quotaRate(provider) {
681
+ const rate = Number(provider?.quotaPerUnit ?? 500000);
682
+ return Number.isFinite(rate) && rate > 0 ? rate : 500000;
683
+ }
684
+
685
+ // magpie cannot show a percent-free consumption window. Use its text status
686
+ // for partial data so spend never becomes a balance or an invented 0% window.
687
+ async function usageOf(getAuth, provider) {
688
+ const auth = await getAuth();
689
+ if (!auth) return { windows: [] };
690
+ const b = await balance(provider, auth);
691
+ if (!b.ok) {
692
+ const spend = await spendOf(provider, auth);
693
+ if (spend.ok) {
694
+ const reason = b.blocked
695
+ ? "余额接口被滑动验证码拦截"
696
+ : b.expired ? "站点拒绝了余额查询凭据,请核对用户名密码或访问令牌" : "余额接口未返回可用余额";
697
+ return {
698
+ error: `AgentRouter:累计消耗 $${spend.spent.toFixed(2)};剩余额度未知(${reason})。读取余额需有效的 Cookie/访问令牌和用户 ID,或站点用户名密码;Cookie 不能保证通过验证码`,
699
+ windows: [],
700
+ };
701
+ }
702
+ const why =
703
+ b.expired
704
+ ? "AgentRouter:站点拒绝了凭据,请核对用户名密码、访问令牌或 Cookie 及用户 ID"
705
+ : b.blocked
706
+ ? "AgentRouter:余额接口被滑动验证码拦截,剩余额度未知。Cookie 不能保证通过验证码;可尝试有效的站点用户名密码读取余额"
707
+ : b.why === "no-token"
708
+ ? "AgentRouter:账号里没有访问令牌,也没有浏览器 Cookie,无法读取余额(个人设置 → 访问令牌)"
709
+ : b.why === "no-quota"
710
+ ? "AgentRouter:站点没有返回额度字段"
711
+ : `AgentRouter:无法读取余额(${b.why})`;
712
+ return { error: why, windows: [] };
713
+ }
714
+ const bal = b.balance;
715
+ // The site counts in 1/500000 dollars unless it says otherwise.
716
+ const rate = quotaRate(provider);
717
+ const dollars = (n) => Math.round((n / rate) * 100) / 100;
718
+
719
+ // New API's fields, as the site's own dashboard labels them:
720
+ // quota the balance left now ("当前余额")
721
+ // used_quota what has been spent ("历史消耗")
722
+ // both the credit the account was given
723
+ // magpie's window wants Used and Remaining as *percent*, with the counts
724
+ // beside them in Amount/Limit/Unit — putting the dollars into Used is what
725
+ // can make a positive balance read as "0% left" when a dollar count is
726
+ // taken for a percentage. The percent is computed here; dollars go where
727
+ // magpie shows counts.
728
+ const left = dollars(bal.quota);
729
+ const spent = dollars(bal.used);
730
+ const whole = dollars(bal.total);
731
+ const usedPct = whole > 0 ? Math.min(100, Math.max(0, (spent / whole) * 100)) : 0;
732
+ const remainingPct = Math.max(0, 100 - usedPct);
733
+
734
+ return {
735
+ plan: bal.name || undefined,
736
+ balance: `$${left.toFixed(2)}`,
737
+ windows: [
738
+ {
739
+ name: "Balance",
740
+ used: Math.round(usedPct * 10) / 10,
741
+ remaining: Math.round(remainingPct * 10) / 10,
742
+ amount: spent,
743
+ limit: whole,
744
+ unit: "usd",
745
+ display: `$${left.toFixed(2)} / $${whole.toFixed(2)}`,
746
+ },
747
+ ],
748
+ };
749
+ }
750
+
751
+ // A completed login and a confirmed reward are different results.
752
+ function checkinResult(checkedIn) {
753
+ if (checkedIn === true) return { outcome: "claimed", message: "AgentRouter:站点确认本次登录签到成功,新增额度已到账" };
754
+ if (checkedIn === false) return { outcome: "done", message: "AgentRouter:已完成登录,站点返回本次未新增签到额度(checked_in=false)" };
755
+ return { outcome: "failed", message: "AgentRouter:登录成功但站点未返回有效签到标记,无法确认额度发放" };
756
+ }
757
+
758
+ // GitHub's session is NOT the AgentRouter session. Start a fresh OAuth login
759
+ // without the old site's identity; otherwise New API may bind instead of login.
760
+ // No automatic redirects, form submissions, or forwarding cookies across sites.
761
+ async function githubCheckin(provider, auth, cookieText) {
762
+ const expectedId = userId(auth);
763
+ if (!/^[1-9][0-9]*$/.test(expectedId)) {
764
+ return { outcome: "ineligible", message: "AgentRouter:GitHub 自动签到需要本站用户 ID,以校验登录账号" };
765
+ }
766
+ const parsed = parseGithubCookie(cookieText);
767
+ if (parsed.error || !parsed.cookie) {
768
+ return { outcome: "ineligible", message: "AgentRouter:" + (parsed.error || "GitHub 自动签到需要 GitHub 登录 Cookie,可稍后重新配置") };
769
+ }
770
+ const githubCookie = parsed.cookie;
771
+ const manual = { outcome: "ineligible", message: "AgentRouter:GitHub 登录态失效或需要授权/二次验证;请在浏览器完成 GitHub 登录并更新 githubCookie" };
772
+ let stage = "获取站点配置";
773
+ try {
774
+ const base = new URL(siteBase(provider, auth));
775
+ if (base.protocol !== "https:" || base.username || base.password || base.pathname !== "/" || base.search || base.hash) {
776
+ return { outcome: "ineligible", message: "AgentRouter:自动签到要求可信的 HTTPS 站点根地址" };
777
+ }
778
+ const signal = withTimeout(REQUEST_TIMEOUT);
779
+ const publicHeaders = { Accept: "application/json", "User-Agent": "Mozilla/5.0" };
780
+ const siteJSON = async (path, cookie = "") => {
781
+ const response = await fetch(base.origin + path, {
782
+ headers: { ...publicHeaders, ...(cookie ? { Cookie: cookie } : {}) },
783
+ redirect: "manual", signal,
784
+ });
785
+ if (!response.ok) throw new Error("site-http");
786
+ const payload = await response.json();
787
+ if (payload?.success !== true) throw new Error("site-rejected");
788
+ return { payload, cookie: sessionOf(response) };
789
+ };
790
+ const { payload: config } = await siteJSON("/api/status");
791
+ if (!config.data?.github_oauth || !config.data?.github_client_id) {
792
+ return { outcome: "ineligible", message: "AgentRouter:当前站点未启用 GitHub OAuth" };
793
+ }
794
+ stage = "建立 OAuth 登录状态";
795
+ const { payload: init, cookie: stateCookie } = await siteJSON("/api/oauth/state?mode=login");
796
+ if (typeof init.data !== "string" || !init.data || !stateCookie) throw new Error("no-state");
797
+ const state = init.data;
798
+ let url = new URL("https://github.com/login/oauth/authorize");
799
+ url.search = new URLSearchParams({client_id: String(config.data.github_client_id), state, scope: "user:email"}).toString();
800
+ stage = "GitHub 重新授权";
801
+ let callback;
802
+ for (let hops = 0; hops < 5; hops++) {
803
+ const response = await fetch(url.href, {
804
+ headers: { Accept: "text/html", "User-Agent": "Mozilla/5.0", Cookie: githubCookie },
805
+ redirect: "manual", signal,
806
+ });
807
+ if (response.status === 200) return manual;
808
+ if (![301, 302, 303, 307, 308].includes(response.status)) throw new Error("github-http");
809
+ const location = response.headers.get("location");
810
+ if (!location) throw new Error("no-location");
811
+ const next = new URL(location, url);
812
+ if (next.protocol !== "https:" || next.username || next.password || next.hash) throw new Error("unsafe-location");
813
+ if (next.origin === base.origin) {
814
+ if (next.pathname !== "/oauth/github" || next.searchParams.get("state") !== state || !next.searchParams.get("code")) {
815
+ throw new Error("invalid-callback");
816
+ }
817
+ callback = next;
818
+ break;
819
+ }
820
+ if (next.origin !== "https://github.com") throw new Error("unexpected-origin");
821
+ if (next.pathname !== "/login/oauth/authorize") return manual;
822
+ if (next.searchParams.get("client_id") !== String(config.data.github_client_id) || next.searchParams.get("state") !== state) {
823
+ throw new Error("changed-oauth-request");
824
+ }
825
+ url = next;
826
+ }
827
+ if (!callback) throw new Error("redirect-limit");
828
+ stage = "兑换 OAuth 登录结果";
829
+ const query = new URLSearchParams({code: callback.searchParams.get("code"), state, mode: "login"});
830
+ const { payload: login } = await siteJSON("/api/oauth/github?" + query, "session=" + stateCookie);
831
+ if (String(login.data?.id ?? "") !== expectedId || login.message === "bind") {
832
+ return { outcome: "ineligible", message: "AgentRouter:GitHub 登录结果与当前账号用户 ID 不符,未将当前账号标记为已签到;请核对每个账号的 githubCookie" };
833
+ }
834
+ return checkinResult(login.data?.checked_in);
835
+ } catch {
836
+ // Exception text can contain cookies or one-time codes; never echo it.
837
+ return { outcome: "failed", message: "AgentRouter:" + stage + "失败(网络、站点响应或验证码异常),未确认签到,稍后重试" };
838
+ }
839
+ }
840
+
841
+ // checkinOf claims the day's credit the only way this site grants one: by
842
+ // signing in. The site has no /api/user/checkin — that path falls through to
843
+ // New API's admin route /api/user/:id and answers every ordinary account
844
+ // "权限不足", the same as /api/user/foo would — and its front end says
845
+ // "签到成功,新增额度已到账" when a login response carries checked_in. So
846
+ // signing in *is* the check-in. GitHub-only accounts need a GitHub session;
847
+ // their site access token or site session cannot repeat the OAuth login.
848
+ //
849
+ // What comes back is `claimed` when this sign-in granted the day, and `done`
850
+ // when the site explicitly reports no new grant (not proof of an earlier one). A
851
+ // wrong password is the account's to fix and settles for the day; a site
852
+ // that is down is retried, as magpie retries any `failed`.
853
+ async function checkinOf(getAuth, provider) {
854
+ const auth = await getAuth();
855
+ if (!auth) return { outcome: "failed", message: "AgentRouter:尚未登录" };
856
+ // Explicit skip wins over stale hidden Cookie metadata, including on older hosts.
857
+ const githubCookie = meta(auth, "githubCookieMode") === "skip" ? "" :
858
+ (auth.githubCookie ?? auth.metadata?.githubCookie);
859
+ // Only parser-recognized blanks fall back; malformed saved values must still be rejected.
860
+ if (parseGithubCookie(githubCookie ?? "").cookie !== "") {
861
+ return githubCheckin(provider, auth, githubCookie);
862
+ }
863
+ const r = await signIn(provider, auth);
864
+ if (!r.ok) {
865
+ if (r.why === "no-password") {
866
+ return {
867
+ outcome: "ineligible",
868
+ message:
869
+ "AgentRouter:GitHub 自动签到需要额外配置 githubCookie(GitHub 登录态);现有站点 Cookie/访问令牌不能重新完成 GitHub 登录",
870
+ };
871
+ }
872
+ if (r.why === "bad-password") {
873
+ return {
874
+ outcome: "ineligible",
875
+ message: `AgentRouter:站点拒绝了登录,请核对账号里的用户名和密码(站点返回「${r.message ?? r.why}」)`,
876
+ signIn: "expired",
877
+ };
878
+ }
879
+ if (r.blocked) {
880
+ return { outcome: "failed", message: "AgentRouter:登录接口被站点验证码拦截,稍后重试" };
881
+ }
882
+ return { outcome: "failed", message: `AgentRouter:登录失败(${r.why})` };
883
+ }
884
+ return checkinResult(r.checkedIn);
885
+ }
886
+
887
+ // declaredModels is the models this plugin vouches for, in the shape magpie
888
+ // wants: a fresh object each time, because magpie edits what it is given.
889
+ function declaredModels() {
890
+ const out = {};
891
+ for (const [id, name] of Object.entries(DECLARED_MODELS)) out[id] = { ...MODEL_SHAPE, name };
892
+ return out;
893
+ }
894
+
895
+ // modelsOf asks the vendor for the models this account has. It is the same
896
+ // list every agent sees, and the vendor keeps it current.
897
+ async function modelsOf(provider, { auth } = {}) {
898
+ // The fallback is this plugin's own list, never the row's: the row may
899
+ // still carry models a cached models.dev catalog gave it, and handing those
900
+ // back would advertise models the site no longer serves.
901
+ const fallback = declaredModels();
902
+ if (!auth) return fallback;
903
+ const key = apiKey(auth);
904
+ if (!key) return fallback;
905
+ try {
906
+ const res = await fetch(`${apiBase(provider, auth)}/models`, {
907
+ headers: applyHeaders(new Headers(), { ...headersOf(provider, auth), Authorization: `Bearer ${key}` }),
908
+ signal: withTimeout(REQUEST_TIMEOUT),
909
+ });
910
+ const { json } = await jsonOrText(res);
911
+ if (!res.ok || !Array.isArray(json?.data)) return fallback;
912
+ const out = {};
913
+ for (const m of json.data) {
914
+ if (!m?.id) continue;
915
+ out[m.id] = {
916
+ ...MODEL_SHAPE,
917
+ name: m.name ?? m.display_name ?? m.id,
918
+ limit: {
919
+ context: Number(m.context_length ?? m.context_window ?? MODEL_SHAPE.limit.context) || MODEL_SHAPE.limit.context,
920
+ output: Number(m.max_output_tokens ?? m.max_tokens ?? MODEL_SHAPE.limit.output) || MODEL_SHAPE.limit.output,
921
+ },
922
+ ...(m.supported_endpoint_types?.length ? { endpoints: m.supported_endpoint_types } : {}),
923
+ // The vendor says "openai" for what is its Responses API; the gateway
924
+ // reads that as the protocol the model speaks.
925
+ responses: true,
926
+ };
927
+ }
928
+ return Object.keys(out).length ? out : fallback;
929
+ } catch {
930
+ return fallback;
931
+ }
932
+ }
933
+
934
+ export const AgentRouterAuthPlugin = async (input) => {
935
+ // The host hands over its reader for the provider's switches; an older one
936
+ // has none, and the Codex headers stay on there (README, "为什么需要自定义
937
+ // 请求头"). Reading it through a closure keeps headersOf's own signature —
938
+ // provider and account — which every caller already passes.
939
+ codexHeaders = () => input?.magpie?.option?.(PROVIDER, "send-codex-headers") !== false;
940
+ const portableResponses = () => input?.magpie?.option?.(PROVIDER, "portable-responses") !== false;
941
+ const textFilterEnabled = () => input?.magpie?.option?.(PROVIDER, "text-filter") !== false;
942
+ return {
943
+ // The provider before anyone signs in: the models the vendor serves, so the
944
+ // row has something to show, and the shape each listed one takes.
945
+ //
946
+ // The vendor's models answer the Responses API only: a chat-completions
947
+ // request is refused with "does not support /v1/chat/completions, please use
948
+ // /v1/responses", whatever the key. So the provider speaks Responses, and
949
+ // the gateway translates an agent's Chat or Anthropic request into it.
950
+ config: async (cfg) => {
951
+ cfg.provider ??= {};
952
+ cfg.provider[PROVIDER] ??= {
953
+ name: "AgentRouter",
954
+ npm: "@ai-sdk/openai",
955
+ api: `${ENDPOINT}/v1`,
956
+ models: declaredModels(),
957
+ };
958
+ },
959
+
960
+ auth: {
961
+ provider: PROVIDER,
962
+
963
+ // Both credentials, because the vendor splits them: the key makes the
964
+ // requests, and the site account signs in to claim the day and read the
965
+ // balance.
966
+ methods: [
967
+ {
968
+ type: "api",
969
+ label: "API Key(推荐)",
970
+ // The current host owns the one native Key input and saves it.
971
+ // Its authorize callback does not receive that Key for validation.
972
+ ...(input?.magpie ? {
973
+ authorize: async () => ({ type: "success", metadata: { site: "org" } }),
974
+ } : {}),
975
+ },
976
+ {
977
+ type: "oauth",
978
+ label: "GitHub(需系统访问令牌)",
979
+ authorize: async (inputs) => {
980
+ const token = String(inputs?.accessToken ?? "").trim();
981
+ if (!token) return { type: "failed", error: "请输入系统访问令牌;如已有模型 Key,请改用 API Key 接入。" };
982
+ const selected = SITES.org;
983
+ let state, cfg;
984
+ try {
985
+ const st = await oauthState(selected.site);
986
+ if (!st.ok || typeof st.state !== "string" || !st.state.trim()) throw new Error("invalid OAuth state");
987
+ state = st.state;
988
+ cfg = await siteAuthConfig(selected.site);
989
+ if (!cfg.ok || !cfg.id) throw new Error("invalid OAuth configuration");
990
+ } catch {
991
+ return { type: "failed", error: "无法获取 GitHub 授权配置,请检查网络或站点验证后重试,也可改用 API Key 接入。" };
992
+ }
993
+ const u = new URL("https://github.com/login/oauth/authorize");
994
+ const redirectUri = callbackUrl(cfg.origin);
995
+ u.searchParams.set("client_id", cfg.id);
996
+ u.searchParams.set("redirect_uri", redirectUri);
997
+ u.searchParams.set("scope", "user:email");
998
+ u.searchParams.set("state", state);
999
+ return {
1000
+ url: u.href,
1001
+ method: "code",
1002
+ instructions: "若未自动打开浏览器,请手动打开链接并完成 GitHub 授权。最终页面可能显示「页面未找到」,请勿刷新;复制地址栏的完整回调地址并粘贴回来,保留 code 和 state。授权码一次有效,失效后请重新登录。插件只查找已有模型 Key;失败时可改用 API Key 接入。请勿将回调地址发到聊天或日志。",
1003
+ callback: async (value) => {
1004
+ let cb;
1005
+ try {
1006
+ if (typeof value !== "string" || !value.trim().startsWith("https://")) throw new Error("full callback required");
1007
+ cb = githubCallbackCode(value, redirectUri, state);
1008
+ if (!cb) throw new Error("invalid callback");
1009
+ } catch {
1010
+ return { type: "failed", error: "回调地址无效或 state 不匹配。请重新登录获得新链接,再粘贴完整回调地址。" };
1011
+ }
1012
+ try {
1013
+ return await redeemGithub(selected.site, cb, state, { accessToken: token });
1014
+ } catch {
1015
+ return { type: "failed", error: "GitHub 授权兑换请求失败或超时,请重新授权重试或改用 API Key 接入。" };
1016
+ }
1017
+ },
1018
+ };
1019
+ },
1020
+ prompts: [
1021
+ {
1022
+ type: "password",
1023
+ key: "accessToken",
1024
+ message: "系统访问令牌(AgentRouter 个人设置 → 访问令牌;不是模型 API Key)",
1025
+ validate: value => String(value ?? "").trim() ? undefined : "请输入系统访问令牌;如已有模型 Key,请改用 API Key 入口",
1026
+ },
1027
+ ],
1028
+ },
1029
+ ],
1030
+
1031
+ // Inline replay avoids resource-local item lookups when complete contents
1032
+ // are present. Server-only references and unknown kinds stay untouched.
1033
+ // Response normalization preserves successful bytes and never retries.
1034
+ async loader(getAuth, provider) {
1035
+ const auth = (await getAuth()) ?? {};
1036
+ const textFilter = textFilterEnabled() ? await loadTextFilter() : null;
1037
+ const base = apiBase(provider, auth);
1038
+ return {
1039
+ baseURL: base,
1040
+ apiKey: apiKey(auth) || "agentrouter",
1041
+ async fetch(input, init = {}) {
1042
+ const key = apiKey(auth);
1043
+ if (!usableModelKey(key)) {
1044
+ return Response.json({ error: {
1045
+ type: "invalid_request_error",
1046
+ code: "agentrouter_missing_api_key",
1047
+ message: "尚未配置模型 API Key,请使用 API Key 入口补充。系统访问令牌和 GitHub Cookie 不能用于模型调用。",
1048
+ } }, { status: 400 });
1049
+ }
1050
+ const h = new Headers(input instanceof Request ? input.headers : undefined);
1051
+ new Headers(init.headers ?? {}).forEach((v, k) => h.set(k, v));
1052
+ applyHeaders(h, headersOf(provider, auth));
1053
+ if (key) h.set("Authorization", `Bearer ${key}`);
1054
+ const requestInit = { ...init, headers: h };
1055
+ const prepared = await prepareRequest(input, requestInit, {
1056
+ portableResponses: portableResponses(),
1057
+ textFilter: textFilterEnabled() ? textFilter : null,
1058
+ });
1059
+ return explainResponse(await fetch(input, prepared));
1060
+ },
1061
+ };
1062
+ },
1063
+
1064
+ usage: usageOf,
1065
+ checkin: checkinOf,
1066
+
1067
+ // Switches the user can turn on the provider's editor.
1068
+ options: [
1069
+ {
1070
+ key: "text-filter",
1071
+ label: "Filter configured prose before sending",
1072
+ default: true,
1073
+ description: "Uses content-filter.local.json; no guessed terms. Preserves recognized Markdown code, tool data and encrypted state. Reload after editing rules.",
1074
+ },
1075
+ {
1076
+ key: "portable-responses",
1077
+ label: "Replay complete Responses items inline",
1078
+ description: "Remove resource-local IDs only when inline content is available; preserve call_id and encrypted content. Reference-only history still requires the original resource. Off disables ID replay only; text-filter is independent.",
1079
+ default: true,
1080
+ },
1081
+ {
1082
+ key: "send-codex-headers",
1083
+ label: "Send the Codex client headers",
1084
+ description:
1085
+ "AgentRouter's gateway only answers clients it recognises. On, requests are sent as a Codex CLI's; off, only the headers you gave are sent.",
1086
+ default: true,
1087
+ },
1088
+ ],
1089
+ },
1090
+
1091
+ provider: {
1092
+ id: PROVIDER,
1093
+ models: modelsOf,
1094
+ },
1095
+
1096
+ // A conversation's turns reach the vendor under one id, so its cache sees
1097
+ // them as one conversation.
1098
+ "chat.headers": async (input, output) => {
1099
+ if (input?.model?.providerID !== PROVIDER && input?.provider?.info?.id !== PROVIDER) return;
1100
+ if (input.sessionID) output.headers["x-session-id"] = input.sessionID;
1101
+ },
1102
+ };
1103
+ };
1104
+
1105
+ export default AgentRouterAuthPlugin;
1106
+ export const _internal = {
1107
+ PROVIDER,
1108
+ SITES,
1109
+ DEFAULT_HEADERS,
1110
+ DECLARED_MODELS,
1111
+ parseHeaders,
1112
+ parseCookies,
1113
+ codeOf,
1114
+ headersOf,
1115
+ applyHeaders,
1116
+ apiBase,
1117
+ siteBase,
1118
+ userHeaders,
1119
+ masked,
1120
+ isURLish,
1121
+ declaredModels,
1122
+ };