opencode-agentrouter-auth 0.0.0-stage → 0.1.5
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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/LOCAL-CHECKIN.md +70 -0
- package/README.md +584 -2
- package/body-inspection.mjs +32 -0
- package/content-filter.example.json +4 -0
- package/content-filter.mjs +219 -0
- package/docs/OPTIMIZATION.md +235 -0
- package/github-cookie.mjs +73 -0
- package/index.mjs +1218 -0
- package/local-checkin.mjs +270 -0
- package/package.json +40 -3
- package/request.mjs +94 -0
- package/response.mjs +308 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,1218 @@
|
|
|
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
|
+
// The form and check-in use the same local parser. A missing mode is a legacy
|
|
328
|
+
// caller; explicit skip must erase a hidden value when Magpie merges metadata.
|
|
329
|
+
function githubCookieForAuth(inputs) {
|
|
330
|
+
const mode = inputs?.githubCookieMode;
|
|
331
|
+
if (mode === "skip") return "";
|
|
332
|
+
if (mode !== undefined && mode !== "configure") {
|
|
333
|
+
throw new Error("请选择跳过或配置 GitHub 自动签到");
|
|
334
|
+
}
|
|
335
|
+
const parsed = parseGithubCookie(inputs?.githubCookie);
|
|
336
|
+
if (parsed.error) throw new Error(parsed.error);
|
|
337
|
+
return parsed.cookie;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function githubCookiePrompts() {
|
|
341
|
+
return [
|
|
342
|
+
{
|
|
343
|
+
type: "select",
|
|
344
|
+
key: "githubCookieMode",
|
|
345
|
+
message: "是否配置 GitHub 签到续期?跳过不影响普通登录和 API 调用,可稍后配置",
|
|
346
|
+
options: [
|
|
347
|
+
{ label: "跳过,暂不配置(推荐)", value: "skip" },
|
|
348
|
+
{ label: "配置 GitHub Cookie", value: "configure" },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
type: "password",
|
|
353
|
+
key: "githubCookie",
|
|
354
|
+
when: { key: "githubCookieMode", op: "eq", value: "configure" },
|
|
355
|
+
message: "GitHub 登录 Cookie(可选,可留空放弃配置)\n" +
|
|
356
|
+
"1. 在自己的浏览器打开 https://github.com,登录与 AgentRouter 绑定的同一 GitHub 账号。\n" +
|
|
357
|
+
"2. 打开开发者工具 → Network/网络,刷新页面,选择发往 github.com 的页面请求。\n" +
|
|
358
|
+
"3. 在 Request Headers/请求标头中找到 Cookie,只复制其值回到这里粘贴;不要复制 Set-Cookie、整份标头或 AgentRouter Cookie。\n" +
|
|
359
|
+
"4. 无需运行控制台脚本;document.cookie 无法取得完整的 HttpOnly 登录 Cookie。\n" +
|
|
360
|
+
"这是高权限凭证,会提交并保存到自己的网关,仅在可信网关使用。勿发到聊天、截图、日志或公共仓库。插件不能自动读取浏览器或剪贴板,也不能自动弹出 Cookie 获取窗口。\n" +
|
|
361
|
+
"找不到、不愿提供或暂不配置可留空或选择跳过。格式合格不代表线上有效或账号正确;Cookie 失效或需要二次验证时仍须人工更新,不保证永久自动签到。",
|
|
362
|
+
placeholder: "user_session=…; logged_in=yes(非 AgentRouter session)",
|
|
363
|
+
validate: value => parseGithubCookie(value).error,
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Full callback URLs must belong to this flow. A code alone remains supported.
|
|
369
|
+
function githubCallbackCode(pasted, expectedURL, state) {
|
|
370
|
+
const text = String(pasted ?? "").trim();
|
|
371
|
+
if (!text || text.length > 8192) return "";
|
|
372
|
+
if (/^[A-Za-z0-9_-]{8,2048}$/.test(text)) return text;
|
|
373
|
+
try {
|
|
374
|
+
const url = new URL(text);
|
|
375
|
+
const expected = new URL(expectedURL);
|
|
376
|
+
const params = url.searchParams;
|
|
377
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname ||
|
|
378
|
+
url.username || url.password || url.hash || params.has("error") ||
|
|
379
|
+
params.getAll("state").length !== 1 || params.get("state") !== state ||
|
|
380
|
+
params.getAll("code").length !== 1) return "";
|
|
381
|
+
const code = params.get("code");
|
|
382
|
+
return /^[A-Za-z0-9_-]{8,2048}$/.test(code ?? "") ? code : "";
|
|
383
|
+
} catch {
|
|
384
|
+
return "";
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// callbackUrl is where GitHub is asked to send the browser. It has to sit on
|
|
389
|
+
// the origin the site's app registered — the site's own web routes, which the
|
|
390
|
+
// site reports — because GitHub checks the callback's host, and the code is
|
|
391
|
+
// checked against the site's app rather than against anything here.
|
|
392
|
+
const callbackUrl = (origin) => `${origin}${GITHUB_CALLBACK_PATH}`;
|
|
393
|
+
|
|
394
|
+
// oauthState asks the site for the state its GitHub sign-in expects. The site
|
|
395
|
+
// issues it and later checks it on the callback, so it can't be made up here.
|
|
396
|
+
async function oauthState(base) {
|
|
397
|
+
try {
|
|
398
|
+
const res = await fetch(`${base}/api/oauth/state?mode=login`, {
|
|
399
|
+
headers: DEFAULT_HEADERS,
|
|
400
|
+
signal: withTimeout(REQUEST_TIMEOUT),
|
|
401
|
+
});
|
|
402
|
+
const { json } = await jsonOrText(res);
|
|
403
|
+
const st = String(json?.data ?? "").trim();
|
|
404
|
+
if (!json?.success || !st) return { ok: false, why: "站点没有下发 OAuth state" };
|
|
405
|
+
return { ok: true, state: st };
|
|
406
|
+
} catch (e) {
|
|
407
|
+
return { ok: false, why: `拿不到 OAuth state:${e?.message ?? String(e)}` };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// siteAuthConfig reads the facts the authorize URL needs out of the site's own
|
|
412
|
+
// status: the GitHub app whose callback the site owns, and the origin that
|
|
413
|
+
// callback lives on. The origin is not simply the front door the user picked —
|
|
414
|
+
// the vendor runs two (org and air) for the same accounts, and GitHub matches
|
|
415
|
+
// the callback against the app's registered host, which only the site's own
|
|
416
|
+
// `server_address` names.
|
|
417
|
+
async function siteAuthConfig(base) {
|
|
418
|
+
try {
|
|
419
|
+
const res = await fetch(`${base}/api/status`, {
|
|
420
|
+
headers: DEFAULT_HEADERS,
|
|
421
|
+
signal: withTimeout(REQUEST_TIMEOUT),
|
|
422
|
+
});
|
|
423
|
+
const { json } = await jsonOrText(res);
|
|
424
|
+
const id = String(json?.data?.github_client_id ?? "").trim();
|
|
425
|
+
if (!id) return { ok: false, why: "站点没有开启 GitHub 登录" };
|
|
426
|
+
const reported = String(json?.data?.server_address ?? "").trim().replace(/\/+$/, "");
|
|
427
|
+
return { ok: true, id, origin: reported || base.replace(/\/+$/, "") };
|
|
428
|
+
} catch (e) {
|
|
429
|
+
return { ok: false, why: `读不到站点配置:${e?.message ?? String(e)}` };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// codeOf pulls the code out of what the user pasted: the whole callback URL
|
|
434
|
+
// (…/oauth/github?code=abc&state=…), or the code alone.
|
|
435
|
+
function codeOf(pasted) {
|
|
436
|
+
const s = String(pasted ?? "").trim();
|
|
437
|
+
if (!s) return "";
|
|
438
|
+
const m = /[?&]code=([^&#\s]+)/.exec(s);
|
|
439
|
+
if (m) {
|
|
440
|
+
try {
|
|
441
|
+
return decodeURIComponent(m[1]);
|
|
442
|
+
} catch {
|
|
443
|
+
return m[1];
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// A bare code has no URL around it; anything with spaces or a scheme is
|
|
447
|
+
// not one.
|
|
448
|
+
if (/^[A-Za-z0-9_-]{8,}$/.test(s)) return s;
|
|
449
|
+
return "";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// sessionOf pulls the site's session cookie out of a response. This site
|
|
453
|
+
// answers a sign-in by setting `session` rather than by putting a token in
|
|
454
|
+
// the body, and `get("set-cookie")` joins every cookie with ", " — a
|
|
455
|
+
// character that also appears inside one (an Expires date) — so the separate
|
|
456
|
+
// entries are what get read when the runtime offers them.
|
|
457
|
+
function sessionOf(res) {
|
|
458
|
+
let lines = [];
|
|
459
|
+
try {
|
|
460
|
+
if (typeof res.headers.getSetCookie === "function") lines = res.headers.getSetCookie() ?? [];
|
|
461
|
+
} catch {
|
|
462
|
+
lines = [];
|
|
463
|
+
}
|
|
464
|
+
if (!lines.length) {
|
|
465
|
+
const joined = res.headers.get("set-cookie");
|
|
466
|
+
if (joined) lines = [joined];
|
|
467
|
+
}
|
|
468
|
+
for (const line of lines) {
|
|
469
|
+
const m = /(?:^|[;,])\s*session=([^;,\s]+)/.exec(String(line));
|
|
470
|
+
if (m) return m[1];
|
|
471
|
+
}
|
|
472
|
+
return "";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// redeemGithub trades the code for the site's sign-in. The answer is the user
|
|
476
|
+
// record: `quota` is the balance and `checked_in` says whether this sign-in
|
|
477
|
+
// granted the day's credit, and the same response carries the session the
|
|
478
|
+
// /api/user routes take. The account keeps this session alongside the
|
|
479
|
+
// system access token explicitly entered before authorization.
|
|
480
|
+
async function redeemGithub(base, code, state, inputs) {
|
|
481
|
+
let json;
|
|
482
|
+
let session = "";
|
|
483
|
+
try {
|
|
484
|
+
const res = await fetch(
|
|
485
|
+
`${base}/api/oauth/github?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}&mode=login`,
|
|
486
|
+
{ headers: DEFAULT_HEADERS, signal: withTimeout(REQUEST_TIMEOUT) },
|
|
487
|
+
);
|
|
488
|
+
session = sessionOf(res);
|
|
489
|
+
({ json } = await jsonOrText(res));
|
|
490
|
+
} catch {
|
|
491
|
+
return { type: "failed", error: "兑换 GitHub 授权失败,请检查网络后重新发起登录;不要重复使用旧授权码" };
|
|
492
|
+
}
|
|
493
|
+
if (!json?.success) {
|
|
494
|
+
return { type: "failed", error: "GitHub 授权兑换失败,授权码可能已使用或过期;请重新发起登录并复制新的完整回调地址" };
|
|
495
|
+
}
|
|
496
|
+
const d = json.data ?? {};
|
|
497
|
+
const id = d.id ?? "";
|
|
498
|
+
// Keep the explicitly entered system token; regenerating it here would
|
|
499
|
+
// invalidate the token the user just supplied.
|
|
500
|
+
const token = String(inputs?.accessToken ?? "").trim();
|
|
501
|
+
if (!token) return { type: "failed", error: "请填写系统访问令牌(AgentRouter 个人设置 → 访问令牌)后重新登录" };
|
|
502
|
+
|
|
503
|
+
// The completions key is not in this answer either, so it is read with the
|
|
504
|
+
// token: the account's own key list. A site that refuses leaves the key
|
|
505
|
+
// empty, and the account then reads as signed in but unable to chat, which
|
|
506
|
+
// its row says.
|
|
507
|
+
const key = await keyOf(base, token, id);
|
|
508
|
+
return {
|
|
509
|
+
type: "success",
|
|
510
|
+
// magpie saves this as an API sign-in: the key for completions, the
|
|
511
|
+
// access token beside it for the balance and the check-in, and the
|
|
512
|
+
// browser session the site itself set, which is what gets a /api/user
|
|
513
|
+
// read past the WAF.
|
|
514
|
+
key: key ?? "",
|
|
515
|
+
metadata: {
|
|
516
|
+
accessToken: token,
|
|
517
|
+
userId: String(id ?? ""),
|
|
518
|
+
site: String(inputs?.site ?? "org"),
|
|
519
|
+
...(session ? { cookie: `session=${session}` } : {}),
|
|
520
|
+
githubCookie: inputs?.githubCookie ?? "",
|
|
521
|
+
...(inputs?.githubCookieMode ? { githubCookieMode: inputs.githubCookieMode } : {}),
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// keyOf reads the account's key with the access token just obtained.
|
|
527
|
+
// /api/token/ is a normal user route and answers JSON, so it works where
|
|
528
|
+
// /api/user/self may be behind the WAF.
|
|
529
|
+
//
|
|
530
|
+
// The key is taken as the site stores it rather than required to start with
|
|
531
|
+
// "sk-": New API keeps a key exactly as it was generated, and this site's own
|
|
532
|
+
// list holds the bare 48 characters, which its /v1 routes accept on their own.
|
|
533
|
+
// An empty entry is the only thing that means "no key".
|
|
534
|
+
async function keyOf(base, token, id) {
|
|
535
|
+
try {
|
|
536
|
+
const res = await fetch(`${base}/api/token/?p=1&page_size=10`, {
|
|
537
|
+
headers: { ...DEFAULT_HEADERS, Authorization: token, "New-Api-User": String(id ?? "") },
|
|
538
|
+
signal: withTimeout(REQUEST_TIMEOUT),
|
|
539
|
+
});
|
|
540
|
+
const { json } = await jsonOrText(res);
|
|
541
|
+
const items = json?.data?.items;
|
|
542
|
+
if (!Array.isArray(items)) return "";
|
|
543
|
+
const live = items.find((x) => x && x.status === 1 && typeof x.key === "string") ?? items.find((x) => x && typeof x.key === "string");
|
|
544
|
+
return String(live?.key ?? "").trim();
|
|
545
|
+
} catch {
|
|
546
|
+
return "";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// signIn is one POST to the site's login route, and it is the whole feature:
|
|
551
|
+
// the login response carries both the day's check-in and the balance.
|
|
552
|
+
//
|
|
553
|
+
// The vendor grants its daily credit at sign-in rather than through a route
|
|
554
|
+
// of its own — its front end says "签到成功,新增额度已到账" when the login
|
|
555
|
+
// response has checked_in — and that same response carries quota and
|
|
556
|
+
// used_quota, the account's balance. So one call does both jobs, and it is
|
|
557
|
+
// also the only way to read the balance from a server: /api/user/self, the
|
|
558
|
+
// route New API normally reads it from, is the one this site's WAF answers
|
|
559
|
+
// with a slider captcha, while /api/user/login is left alone.
|
|
560
|
+
//
|
|
561
|
+
// It answers {ok, checkedIn?, balance?, why?}. The caller decides whether a
|
|
562
|
+
// false `ok` is the account's to fix (wrong password) or nobody's (the site
|
|
563
|
+
// is down), so the reasons stay distinguishable here.
|
|
564
|
+
async function signIn(provider, auth) {
|
|
565
|
+
const user = String(meta(auth, "username") ?? "").trim();
|
|
566
|
+
const pass = String(meta(auth, "password") ?? "");
|
|
567
|
+
if (!user || !pass) return { ok: false, why: "no-password" };
|
|
568
|
+
const h = headersOf(provider, auth);
|
|
569
|
+
h["Content-Type"] = "application/json";
|
|
570
|
+
delete h.Authorization;
|
|
571
|
+
let res;
|
|
572
|
+
try {
|
|
573
|
+
res = await fetch(`${siteBase(provider, auth)}/api/user/login`, {
|
|
574
|
+
method: "POST",
|
|
575
|
+
headers: h,
|
|
576
|
+
body: JSON.stringify({ username: user, password: pass }),
|
|
577
|
+
signal: withTimeout(REQUEST_TIMEOUT),
|
|
578
|
+
});
|
|
579
|
+
} catch (e) {
|
|
580
|
+
return { ok: false, why: `unreachable: ${e?.message ?? String(e)}` };
|
|
581
|
+
}
|
|
582
|
+
const { json, text } = await jsonOrText(res);
|
|
583
|
+
if (!json) {
|
|
584
|
+
if (/^\s*<(?:!doctype|html)/i.test(String(text))) return { ok: false, why: "blocked", blocked: true };
|
|
585
|
+
return { ok: false, why: `HTTP ${res.status}` };
|
|
586
|
+
}
|
|
587
|
+
if (json.success === false) {
|
|
588
|
+
const msg = String(json.message ?? json.msg ?? "");
|
|
589
|
+
// The site words a wrong password and a banned account the same way, so
|
|
590
|
+
// this is "the site refused these credentials", which is the account's
|
|
591
|
+
// to fix.
|
|
592
|
+
if (/密码|password|封禁|banned|错误/i.test(msg)) return { ok: false, why: "bad-password", message: msg };
|
|
593
|
+
return { ok: false, why: msg || "refused", message: msg };
|
|
594
|
+
}
|
|
595
|
+
const d = json.data ?? {};
|
|
596
|
+
const quota = quotaNumber(d.quota);
|
|
597
|
+
const used = quotaNumber(d.used_quota);
|
|
598
|
+
const balance =
|
|
599
|
+
Number.isFinite(quota) && Number.isFinite(used)
|
|
600
|
+
? { quota, used, name: d.username ?? d.display_name ?? "" }
|
|
601
|
+
: undefined;
|
|
602
|
+
if (balance) balance.total = balance.quota + balance.used;
|
|
603
|
+
// `checked_in: true` means *this* sign-in granted the day's credit. A false
|
|
604
|
+
// means no new grant; a missing/non-boolean marker proves nothing.
|
|
605
|
+
return { ok: true, checkedIn: d.checked_in, balance, name: d.username ?? d.display_name ?? "" };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Try the read-only user endpoint first, then password sign-in. Preserve
|
|
609
|
+
// the failure reason when neither returns a complete quota record.
|
|
610
|
+
async function balance(provider, auth) {
|
|
611
|
+
const viaToken = await balanceByToken(provider, auth);
|
|
612
|
+
if (viaToken.ok) return viaToken;
|
|
613
|
+
const viaLogin = await signIn(provider, auth);
|
|
614
|
+
if (viaLogin.ok && viaLogin.balance) return { ok: true, balance: viaLogin.balance };
|
|
615
|
+
if (viaLogin.ok) return { ok: false, why: "no-quota" };
|
|
616
|
+
// Report the reason the user can act on: a wrong password outranks a
|
|
617
|
+
// captcha, and a missing password means the token route's failure is the
|
|
618
|
+
// real one.
|
|
619
|
+
if (viaLogin.why === "bad-password") return { ok: false, why: "expired", expired: true, message: viaLogin.message };
|
|
620
|
+
if (viaLogin.why !== "no-password") return viaLogin;
|
|
621
|
+
return viaToken;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Read /api/user/self with either an access token or a browser session.
|
|
625
|
+
async function balanceByToken(provider, auth) {
|
|
626
|
+
if (!userToken(auth) && !parseCookies(cookieText(auth))) return { ok: false, why: "no-token" };
|
|
627
|
+
try {
|
|
628
|
+
const res = await fetch(`${siteBase(provider, auth)}/api/user/self`, {
|
|
629
|
+
headers: userHeaders(provider, auth),
|
|
630
|
+
signal: withTimeout(REQUEST_TIMEOUT),
|
|
631
|
+
});
|
|
632
|
+
const { json, text } = await jsonOrText(res);
|
|
633
|
+
// New API answers 200 with success:false for a token it refuses, so the
|
|
634
|
+
// status alone doesn't say; the body does.
|
|
635
|
+
if (json && json.success === false) {
|
|
636
|
+
const msg = String(json.message ?? "");
|
|
637
|
+
if (/access\s*token|token.*(无效|invalid)|未登录/i.test(msg)) return { ok: false, why: "expired", expired: true };
|
|
638
|
+
return { ok: false, why: msg || "refused" };
|
|
639
|
+
}
|
|
640
|
+
if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
|
|
641
|
+
// The site sits behind a WAF that answers an HTML slider-captcha page
|
|
642
|
+
// with 200 instead of the JSON: saying "<!doctype html>" back to the
|
|
643
|
+
// user hides the real problem, which is that the read was blocked and a
|
|
644
|
+
// sign-in is what gets past it.
|
|
645
|
+
if (!json) {
|
|
646
|
+
if (/^\s*<(?:!doctype|html)/i.test(String(text))) {
|
|
647
|
+
return { ok: false, why: "blocked", blocked: true };
|
|
648
|
+
}
|
|
649
|
+
return { ok: false, why: String(text).slice(0, 80) };
|
|
650
|
+
}
|
|
651
|
+
const d = json.data ?? {};
|
|
652
|
+
const quota = quotaNumber(d.quota);
|
|
653
|
+
const used = quotaNumber(d.used_quota);
|
|
654
|
+
if (!Number.isFinite(quota) || !Number.isFinite(used)) return { ok: false, why: "no-quota" };
|
|
655
|
+
return { ok: true, balance: { quota, used, total: quota + used, name: d.username ?? d.display_name ?? "" } };
|
|
656
|
+
} catch (e) {
|
|
657
|
+
return { ok: false, why: `unreachable: ${e?.message ?? String(e)}` };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// These endpoints report cumulative consumption, never remaining credit.
|
|
662
|
+
async function spendOf(provider, auth) {
|
|
663
|
+
const rate = quotaRate(provider);
|
|
664
|
+
const reads = [];
|
|
665
|
+
if (userToken(auth) || parseCookies(cookieText(auth))) {
|
|
666
|
+
reads.push({
|
|
667
|
+
url: `${siteBase(provider, auth)}/api/log/self/stat`,
|
|
668
|
+
headers: userHeaders(provider, auth),
|
|
669
|
+
field: "quota",
|
|
670
|
+
divisor: rate,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
if (apiKey(auth)) {
|
|
674
|
+
reads.push({
|
|
675
|
+
url: `${apiBase(provider, auth)}/dashboard/billing/usage`,
|
|
676
|
+
headers: { ...headersOf(provider, auth), Authorization: `Bearer ${apiKey(auth)}` },
|
|
677
|
+
field: "total_usage",
|
|
678
|
+
divisor: 100,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
for (const read of reads) {
|
|
682
|
+
try {
|
|
683
|
+
const res = await fetch(read.url, { headers: read.headers, signal: withTimeout(REQUEST_TIMEOUT) });
|
|
684
|
+
const { json } = await jsonOrText(res);
|
|
685
|
+
if (!res.ok || !json || json.success === false) continue;
|
|
686
|
+
const value = (json.data ?? json)[read.field];
|
|
687
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) continue;
|
|
688
|
+
return { ok: true, spent: value / read.divisor };
|
|
689
|
+
} catch {
|
|
690
|
+
// A failed user read must not prevent the independent key fallback.
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { ok: false };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function quotaNumber(value) {
|
|
697
|
+
if (typeof value !== "number" && !(typeof value === "string" && value.trim())) return NaN;
|
|
698
|
+
const n = Number(value);
|
|
699
|
+
return Number.isFinite(n) ? n : NaN;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function quotaRate(provider) {
|
|
703
|
+
const rate = Number(provider?.quotaPerUnit ?? 500000);
|
|
704
|
+
return Number.isFinite(rate) && rate > 0 ? rate : 500000;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// magpie cannot show a percent-free consumption window. Use its text status
|
|
708
|
+
// for partial data so spend never becomes a balance or an invented 0% window.
|
|
709
|
+
async function usageOf(getAuth, provider) {
|
|
710
|
+
const auth = await getAuth();
|
|
711
|
+
if (!auth) return { windows: [] };
|
|
712
|
+
const b = await balance(provider, auth);
|
|
713
|
+
if (!b.ok) {
|
|
714
|
+
const spend = await spendOf(provider, auth);
|
|
715
|
+
if (spend.ok) {
|
|
716
|
+
const reason = b.blocked
|
|
717
|
+
? "余额接口被滑动验证码拦截"
|
|
718
|
+
: b.expired ? "站点拒绝了余额查询凭据,请核对用户名密码或访问令牌" : "余额接口未返回可用余额";
|
|
719
|
+
return {
|
|
720
|
+
error: `AgentRouter:累计消耗 $${spend.spent.toFixed(2)};剩余额度未知(${reason})。读取余额需有效的 Cookie/访问令牌和用户 ID,或站点用户名密码;Cookie 不能保证通过验证码`,
|
|
721
|
+
windows: [],
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
const why =
|
|
725
|
+
b.expired
|
|
726
|
+
? "AgentRouter:站点拒绝了凭据,请核对用户名密码、访问令牌或 Cookie 及用户 ID"
|
|
727
|
+
: b.blocked
|
|
728
|
+
? "AgentRouter:余额接口被滑动验证码拦截,剩余额度未知。Cookie 不能保证通过验证码;可尝试有效的站点用户名密码读取余额"
|
|
729
|
+
: b.why === "no-token"
|
|
730
|
+
? "AgentRouter:账号里没有访问令牌,也没有浏览器 Cookie,无法读取余额(个人设置 → 访问令牌)"
|
|
731
|
+
: b.why === "no-quota"
|
|
732
|
+
? "AgentRouter:站点没有返回额度字段"
|
|
733
|
+
: `AgentRouter:无法读取余额(${b.why})`;
|
|
734
|
+
return { error: why, windows: [] };
|
|
735
|
+
}
|
|
736
|
+
const bal = b.balance;
|
|
737
|
+
// The site counts in 1/500000 dollars unless it says otherwise.
|
|
738
|
+
const rate = quotaRate(provider);
|
|
739
|
+
const dollars = (n) => Math.round((n / rate) * 100) / 100;
|
|
740
|
+
|
|
741
|
+
// New API's fields, as the site's own dashboard labels them:
|
|
742
|
+
// quota the balance left now ("当前余额")
|
|
743
|
+
// used_quota what has been spent ("历史消耗")
|
|
744
|
+
// both the credit the account was given
|
|
745
|
+
// magpie's window wants Used and Remaining as *percent*, with the counts
|
|
746
|
+
// beside them in Amount/Limit/Unit — putting the dollars into Used is what
|
|
747
|
+
// can make a positive balance read as "0% left" when a dollar count is
|
|
748
|
+
// taken for a percentage. The percent is computed here; dollars go where
|
|
749
|
+
// magpie shows counts.
|
|
750
|
+
const left = dollars(bal.quota);
|
|
751
|
+
const spent = dollars(bal.used);
|
|
752
|
+
const whole = dollars(bal.total);
|
|
753
|
+
const usedPct = whole > 0 ? Math.min(100, Math.max(0, (spent / whole) * 100)) : 0;
|
|
754
|
+
const remainingPct = Math.max(0, 100 - usedPct);
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
plan: bal.name || undefined,
|
|
758
|
+
balance: `$${left.toFixed(2)}`,
|
|
759
|
+
windows: [
|
|
760
|
+
{
|
|
761
|
+
name: "Balance",
|
|
762
|
+
used: Math.round(usedPct * 10) / 10,
|
|
763
|
+
remaining: Math.round(remainingPct * 10) / 10,
|
|
764
|
+
amount: spent,
|
|
765
|
+
limit: whole,
|
|
766
|
+
unit: "usd",
|
|
767
|
+
display: `$${left.toFixed(2)} / $${whole.toFixed(2)}`,
|
|
768
|
+
},
|
|
769
|
+
],
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// A completed login and a confirmed reward are different results.
|
|
774
|
+
function checkinResult(checkedIn) {
|
|
775
|
+
if (checkedIn === true) return { outcome: "claimed", message: "AgentRouter:站点确认本次登录签到成功,新增额度已到账" };
|
|
776
|
+
if (checkedIn === false) return { outcome: "done", message: "AgentRouter:已完成登录,站点返回本次未新增签到额度(checked_in=false)" };
|
|
777
|
+
return { outcome: "failed", message: "AgentRouter:登录成功但站点未返回有效签到标记,无法确认额度发放" };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// GitHub's session is NOT the AgentRouter session. Start a fresh OAuth login
|
|
781
|
+
// without the old site's identity; otherwise New API may bind instead of login.
|
|
782
|
+
// No automatic redirects, form submissions, or forwarding cookies across sites.
|
|
783
|
+
async function githubCheckin(provider, auth, cookieText) {
|
|
784
|
+
const expectedId = userId(auth);
|
|
785
|
+
if (!/^[1-9][0-9]*$/.test(expectedId)) {
|
|
786
|
+
return { outcome: "ineligible", message: "AgentRouter:GitHub 自动签到需要本站用户 ID,以校验登录账号" };
|
|
787
|
+
}
|
|
788
|
+
const parsed = parseGithubCookie(cookieText);
|
|
789
|
+
if (parsed.error || !parsed.cookie) {
|
|
790
|
+
return { outcome: "ineligible", message: "AgentRouter:" + (parsed.error || "GitHub 自动签到需要 GitHub 登录 Cookie,可稍后重新配置") };
|
|
791
|
+
}
|
|
792
|
+
const githubCookie = parsed.cookie;
|
|
793
|
+
const manual = { outcome: "ineligible", message: "AgentRouter:GitHub 登录态失效或需要授权/二次验证;请在浏览器完成 GitHub 登录并更新 githubCookie" };
|
|
794
|
+
let stage = "获取站点配置";
|
|
795
|
+
try {
|
|
796
|
+
const base = new URL(siteBase(provider, auth));
|
|
797
|
+
if (base.protocol !== "https:" || base.username || base.password || base.pathname !== "/" || base.search || base.hash) {
|
|
798
|
+
return { outcome: "ineligible", message: "AgentRouter:自动签到要求可信的 HTTPS 站点根地址" };
|
|
799
|
+
}
|
|
800
|
+
const signal = withTimeout(REQUEST_TIMEOUT);
|
|
801
|
+
const publicHeaders = { Accept: "application/json", "User-Agent": "Mozilla/5.0" };
|
|
802
|
+
const siteJSON = async (path, cookie = "") => {
|
|
803
|
+
const response = await fetch(base.origin + path, {
|
|
804
|
+
headers: { ...publicHeaders, ...(cookie ? { Cookie: cookie } : {}) },
|
|
805
|
+
redirect: "manual", signal,
|
|
806
|
+
});
|
|
807
|
+
if (!response.ok) throw new Error("site-http");
|
|
808
|
+
const payload = await response.json();
|
|
809
|
+
if (payload?.success !== true) throw new Error("site-rejected");
|
|
810
|
+
return { payload, cookie: sessionOf(response) };
|
|
811
|
+
};
|
|
812
|
+
const { payload: config } = await siteJSON("/api/status");
|
|
813
|
+
if (!config.data?.github_oauth || !config.data?.github_client_id) {
|
|
814
|
+
return { outcome: "ineligible", message: "AgentRouter:当前站点未启用 GitHub OAuth" };
|
|
815
|
+
}
|
|
816
|
+
stage = "建立 OAuth 登录状态";
|
|
817
|
+
const { payload: init, cookie: stateCookie } = await siteJSON("/api/oauth/state?mode=login");
|
|
818
|
+
if (typeof init.data !== "string" || !init.data || !stateCookie) throw new Error("no-state");
|
|
819
|
+
const state = init.data;
|
|
820
|
+
let url = new URL("https://github.com/login/oauth/authorize");
|
|
821
|
+
url.search = new URLSearchParams({client_id: String(config.data.github_client_id), state, scope: "user:email"}).toString();
|
|
822
|
+
stage = "GitHub 重新授权";
|
|
823
|
+
let callback;
|
|
824
|
+
for (let hops = 0; hops < 5; hops++) {
|
|
825
|
+
const response = await fetch(url.href, {
|
|
826
|
+
headers: { Accept: "text/html", "User-Agent": "Mozilla/5.0", Cookie: githubCookie },
|
|
827
|
+
redirect: "manual", signal,
|
|
828
|
+
});
|
|
829
|
+
if (response.status === 200) return manual;
|
|
830
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) throw new Error("github-http");
|
|
831
|
+
const location = response.headers.get("location");
|
|
832
|
+
if (!location) throw new Error("no-location");
|
|
833
|
+
const next = new URL(location, url);
|
|
834
|
+
if (next.protocol !== "https:" || next.username || next.password || next.hash) throw new Error("unsafe-location");
|
|
835
|
+
if (next.origin === base.origin) {
|
|
836
|
+
if (next.pathname !== "/oauth/github" || next.searchParams.get("state") !== state || !next.searchParams.get("code")) {
|
|
837
|
+
throw new Error("invalid-callback");
|
|
838
|
+
}
|
|
839
|
+
callback = next;
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
if (next.origin !== "https://github.com") throw new Error("unexpected-origin");
|
|
843
|
+
if (next.pathname !== "/login/oauth/authorize") return manual;
|
|
844
|
+
if (next.searchParams.get("client_id") !== String(config.data.github_client_id) || next.searchParams.get("state") !== state) {
|
|
845
|
+
throw new Error("changed-oauth-request");
|
|
846
|
+
}
|
|
847
|
+
url = next;
|
|
848
|
+
}
|
|
849
|
+
if (!callback) throw new Error("redirect-limit");
|
|
850
|
+
stage = "兑换 OAuth 登录结果";
|
|
851
|
+
const query = new URLSearchParams({code: callback.searchParams.get("code"), state, mode: "login"});
|
|
852
|
+
const { payload: login } = await siteJSON("/api/oauth/github?" + query, "session=" + stateCookie);
|
|
853
|
+
if (String(login.data?.id ?? "") !== expectedId || login.message === "bind") {
|
|
854
|
+
return { outcome: "ineligible", message: "AgentRouter:GitHub 登录结果与当前账号用户 ID 不符,未将当前账号标记为已签到;请核对每个账号的 githubCookie" };
|
|
855
|
+
}
|
|
856
|
+
return checkinResult(login.data?.checked_in);
|
|
857
|
+
} catch {
|
|
858
|
+
// Exception text can contain cookies or one-time codes; never echo it.
|
|
859
|
+
return { outcome: "failed", message: "AgentRouter:" + stage + "失败(网络、站点响应或验证码异常),未确认签到,稍后重试" };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// checkinOf claims the day's credit the only way this site grants one: by
|
|
864
|
+
// signing in. The site has no /api/user/checkin — that path falls through to
|
|
865
|
+
// New API's admin route /api/user/:id and answers every ordinary account
|
|
866
|
+
// "权限不足", the same as /api/user/foo would — and its front end says
|
|
867
|
+
// "签到成功,新增额度已到账" when a login response carries checked_in. So
|
|
868
|
+
// signing in *is* the check-in. GitHub-only accounts need a GitHub session;
|
|
869
|
+
// their site access token or site session cannot repeat the OAuth login.
|
|
870
|
+
//
|
|
871
|
+
// What comes back is `claimed` when this sign-in granted the day, and `done`
|
|
872
|
+
// when the site explicitly reports no new grant (not proof of an earlier one). A
|
|
873
|
+
// wrong password is the account's to fix and settles for the day; a site
|
|
874
|
+
// that is down is retried, as magpie retries any `failed`.
|
|
875
|
+
async function checkinOf(getAuth, provider) {
|
|
876
|
+
const auth = await getAuth();
|
|
877
|
+
if (!auth) return { outcome: "failed", message: "AgentRouter:尚未登录" };
|
|
878
|
+
// Explicit skip wins over stale hidden Cookie metadata, including on older hosts.
|
|
879
|
+
const githubCookie = meta(auth, "githubCookieMode") === "skip" ? "" :
|
|
880
|
+
(auth.githubCookie ?? auth.metadata?.githubCookie);
|
|
881
|
+
// Only parser-recognized blanks fall back; malformed saved values must still be rejected.
|
|
882
|
+
if (parseGithubCookie(githubCookie ?? "").cookie !== "") {
|
|
883
|
+
return githubCheckin(provider, auth, githubCookie);
|
|
884
|
+
}
|
|
885
|
+
const r = await signIn(provider, auth);
|
|
886
|
+
if (!r.ok) {
|
|
887
|
+
if (r.why === "no-password") {
|
|
888
|
+
return {
|
|
889
|
+
outcome: "ineligible",
|
|
890
|
+
message:
|
|
891
|
+
"AgentRouter:GitHub 自动签到需要额外配置 githubCookie(GitHub 登录态);现有站点 Cookie/访问令牌不能重新完成 GitHub 登录",
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
if (r.why === "bad-password") {
|
|
895
|
+
return {
|
|
896
|
+
outcome: "ineligible",
|
|
897
|
+
message: `AgentRouter:站点拒绝了登录,请核对账号里的用户名和密码(站点返回「${r.message ?? r.why}」)`,
|
|
898
|
+
signIn: "expired",
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
if (r.blocked) {
|
|
902
|
+
return { outcome: "failed", message: "AgentRouter:登录接口被站点验证码拦截,稍后重试" };
|
|
903
|
+
}
|
|
904
|
+
return { outcome: "failed", message: `AgentRouter:登录失败(${r.why})` };
|
|
905
|
+
}
|
|
906
|
+
return checkinResult(r.checkedIn);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// declaredModels is the models this plugin vouches for, in the shape magpie
|
|
910
|
+
// wants: a fresh object each time, because magpie edits what it is given.
|
|
911
|
+
function declaredModels() {
|
|
912
|
+
const out = {};
|
|
913
|
+
for (const [id, name] of Object.entries(DECLARED_MODELS)) out[id] = { ...MODEL_SHAPE, name };
|
|
914
|
+
return out;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// modelsOf asks the vendor for the models this account has. It is the same
|
|
918
|
+
// list every agent sees, and the vendor keeps it current.
|
|
919
|
+
async function modelsOf(provider, { auth } = {}) {
|
|
920
|
+
// The fallback is this plugin's own list, never the row's: the row may
|
|
921
|
+
// still carry models a cached models.dev catalog gave it, and handing those
|
|
922
|
+
// back would advertise models the site no longer serves.
|
|
923
|
+
const fallback = declaredModels();
|
|
924
|
+
if (!auth) return fallback;
|
|
925
|
+
const key = apiKey(auth);
|
|
926
|
+
if (!key) return fallback;
|
|
927
|
+
try {
|
|
928
|
+
const res = await fetch(`${apiBase(provider, auth)}/models`, {
|
|
929
|
+
headers: applyHeaders(new Headers(), { ...headersOf(provider, auth), Authorization: `Bearer ${key}` }),
|
|
930
|
+
signal: withTimeout(REQUEST_TIMEOUT),
|
|
931
|
+
});
|
|
932
|
+
const { json } = await jsonOrText(res);
|
|
933
|
+
if (!res.ok || !Array.isArray(json?.data)) return fallback;
|
|
934
|
+
const out = {};
|
|
935
|
+
for (const m of json.data) {
|
|
936
|
+
if (!m?.id) continue;
|
|
937
|
+
out[m.id] = {
|
|
938
|
+
...MODEL_SHAPE,
|
|
939
|
+
name: m.name ?? m.display_name ?? m.id,
|
|
940
|
+
limit: {
|
|
941
|
+
context: Number(m.context_length ?? m.context_window ?? MODEL_SHAPE.limit.context) || MODEL_SHAPE.limit.context,
|
|
942
|
+
output: Number(m.max_output_tokens ?? m.max_tokens ?? MODEL_SHAPE.limit.output) || MODEL_SHAPE.limit.output,
|
|
943
|
+
},
|
|
944
|
+
...(m.supported_endpoint_types?.length ? { endpoints: m.supported_endpoint_types } : {}),
|
|
945
|
+
// The vendor says "openai" for what is its Responses API; the gateway
|
|
946
|
+
// reads that as the protocol the model speaks.
|
|
947
|
+
responses: true,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
return Object.keys(out).length ? out : fallback;
|
|
951
|
+
} catch {
|
|
952
|
+
return fallback;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export const AgentRouterAuthPlugin = async (input) => {
|
|
957
|
+
// The host hands over its reader for the provider's switches; an older one
|
|
958
|
+
// has none, and the Codex headers stay on there (README, "为什么需要自定义
|
|
959
|
+
// 请求头"). Reading it through a closure keeps headersOf's own signature —
|
|
960
|
+
// provider and account — which every caller already passes.
|
|
961
|
+
codexHeaders = () => input?.magpie?.option?.(PROVIDER, "send-codex-headers") !== false;
|
|
962
|
+
const portableResponses = () => input?.magpie?.option?.(PROVIDER, "portable-responses") !== false;
|
|
963
|
+
const textFilterEnabled = () => input?.magpie?.option?.(PROVIDER, "text-filter") !== false;
|
|
964
|
+
return {
|
|
965
|
+
// The provider before anyone signs in: the models the vendor serves, so the
|
|
966
|
+
// row has something to show, and the shape each listed one takes.
|
|
967
|
+
//
|
|
968
|
+
// The vendor's models answer the Responses API only: a chat-completions
|
|
969
|
+
// request is refused with "does not support /v1/chat/completions, please use
|
|
970
|
+
// /v1/responses", whatever the key. So the provider speaks Responses, and
|
|
971
|
+
// the gateway translates an agent's Chat or Anthropic request into it.
|
|
972
|
+
config: async (cfg) => {
|
|
973
|
+
cfg.provider ??= {};
|
|
974
|
+
cfg.provider[PROVIDER] ??= {
|
|
975
|
+
name: "AgentRouter",
|
|
976
|
+
npm: "@ai-sdk/openai",
|
|
977
|
+
api: `${ENDPOINT}/v1`,
|
|
978
|
+
models: declaredModels(),
|
|
979
|
+
};
|
|
980
|
+
},
|
|
981
|
+
|
|
982
|
+
auth: {
|
|
983
|
+
provider: PROVIDER,
|
|
984
|
+
|
|
985
|
+
// Both credentials, because the vendor splits them: the key makes the
|
|
986
|
+
// requests, and the site account signs in to claim the day and read the
|
|
987
|
+
// balance.
|
|
988
|
+
methods: [
|
|
989
|
+
{
|
|
990
|
+
type: "api",
|
|
991
|
+
label: "AgentRouter API 密钥",
|
|
992
|
+
placeholder: "sk-…",
|
|
993
|
+
prompts: [
|
|
994
|
+
{
|
|
995
|
+
type: "text",
|
|
996
|
+
key: "endpoint",
|
|
997
|
+
message: "请求端点(对话与模型列表)",
|
|
998
|
+
placeholder: ENDPOINT,
|
|
999
|
+
// The key is asked for separately, last. This question is here
|
|
1000
|
+
// because the editor's box is where the key used to go: a key
|
|
1001
|
+
// pasted into it becomes apiBase's endpoint and every request
|
|
1002
|
+
// then goes nowhere, silently. So the answer is checked here,
|
|
1003
|
+
// where the user can still be told.
|
|
1004
|
+
validate: (v) =>
|
|
1005
|
+
!String(v ?? "").trim() || isURLish(v)
|
|
1006
|
+
? undefined
|
|
1007
|
+
: "请填站点地址,例如 https://agentrouter.org(不要在这里填 API 密钥)",
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
type: "select",
|
|
1011
|
+
key: "site",
|
|
1012
|
+
message: "站点(签到与余额走这里)",
|
|
1013
|
+
options: [
|
|
1014
|
+
{ label: "agentrouter.org", value: "org" },
|
|
1015
|
+
{ label: "ps.air-outer.com(备用域名)", value: "air" },
|
|
1016
|
+
],
|
|
1017
|
+
},
|
|
1018
|
+
...githubCookiePrompts(),
|
|
1019
|
+
{
|
|
1020
|
+
type: "text",
|
|
1021
|
+
key: "username",
|
|
1022
|
+
message: "旧版兼容:站点用户名(GitHub-only 账号留空)",
|
|
1023
|
+
placeholder: "AgentRouter GitHub 账号无需填写",
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
type: "text",
|
|
1027
|
+
key: "password",
|
|
1028
|
+
message: "旧版兼容:站点密码(仅支持密码登录的站点适用;保存在网关服务器)",
|
|
1029
|
+
placeholder: "AgentRouter GitHub 账号留空,不需要也不能在这里设置密码",
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
type: "text",
|
|
1033
|
+
key: "cookie",
|
|
1034
|
+
message: "浏览器 Cookie(可选,登录站点后复制请求中的 Cookie;需同时填用户 ID,仍可能被验证码拦截)",
|
|
1035
|
+
placeholder: "session=…",
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
type: "password",
|
|
1039
|
+
key: "accessToken",
|
|
1040
|
+
message: "系统访问令牌(可选,AgentRouter「个人设置 → 访问令牌」;不是 API Key 或 GitHub Token,本站 Cookie 有效时不需要)",
|
|
1041
|
+
placeholder: "留空则用浏览器 Cookie 读取余额",
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
type: "text",
|
|
1045
|
+
key: "userId",
|
|
1046
|
+
message: "本站用户 ID(New-Api-User;API 登录配置 GitHub 自动签到或读取账号数据时需填写;GitHub 登录会自动获取)",
|
|
1047
|
+
placeholder: "例如 1234",
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
type: "text",
|
|
1051
|
+
key: "headers",
|
|
1052
|
+
message: "自定义请求头:每行一个「名称: 值」,留空则用默认的 Codex 客户端标识",
|
|
1053
|
+
placeholder: "User-Agent: codex_cli_rs/0.101.0",
|
|
1054
|
+
},
|
|
1055
|
+
],
|
|
1056
|
+
// Magpie falls back to the separately entered key when this result
|
|
1057
|
+
// omits key. Only normalize metadata; other hosts keep native entry.
|
|
1058
|
+
...(input?.magpie ? { authorize: async (inputs) => ({
|
|
1059
|
+
type: "success",
|
|
1060
|
+
metadata: { ...inputs, githubCookie: githubCookieForAuth(inputs) },
|
|
1061
|
+
}) } : {}),
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
// Signing in the way the site's own "Login with GitHub" button does.
|
|
1065
|
+
//
|
|
1066
|
+
// Ask for the site's system access token before authorization.
|
|
1067
|
+
// GitHub supplies the user ID and session; the entered token is kept
|
|
1068
|
+
// for account reads and is never regenerated by this flow.
|
|
1069
|
+
//
|
|
1070
|
+
// The GitHub app is the site's, so its callback is the site's own
|
|
1071
|
+
// page and the code lands in the browser's address bar rather than at
|
|
1072
|
+
// a server this plugin runs. That is why the code is asked for: the
|
|
1073
|
+
// user pastes the address the browser ended on, and the code is read
|
|
1074
|
+
// out of it.
|
|
1075
|
+
type: "oauth",
|
|
1076
|
+
label: "用 GitHub 登录 AgentRouter(推荐)",
|
|
1077
|
+
authorize: async (inputs) => {
|
|
1078
|
+
const githubCookie = githubCookieForAuth(inputs);
|
|
1079
|
+
if (!String(inputs?.accessToken ?? "").trim()) {
|
|
1080
|
+
throw new Error("请填写系统访问令牌(AgentRouter 个人设置 → 访问令牌,不是 API 密钥或 GitHub Token)");
|
|
1081
|
+
}
|
|
1082
|
+
const site = siteOf(String(inputs?.site ?? "org"));
|
|
1083
|
+
const st = await oauthState(site.site);
|
|
1084
|
+
if (!st.ok) throw new Error(st.why);
|
|
1085
|
+
const cid = await siteAuthConfig(site.site);
|
|
1086
|
+
if (!cid.ok) throw new Error(cid.why);
|
|
1087
|
+
const url =
|
|
1088
|
+
"https://github.com/login/oauth/authorize" +
|
|
1089
|
+
`?client_id=${encodeURIComponent(cid.id)}` +
|
|
1090
|
+
`&state=${encodeURIComponent(st.state)}` +
|
|
1091
|
+
"&scope=user:email" +
|
|
1092
|
+
`&redirect_uri=${encodeURIComponent(callbackUrl(cid.origin))}`;
|
|
1093
|
+
return {
|
|
1094
|
+
url,
|
|
1095
|
+
instructions:
|
|
1096
|
+
"若未自动打开浏览器,请手动打开上方链接。不要刷新授权后的页面,以免一次性 code 被消耗。\n" +
|
|
1097
|
+
`在打开的页面用 GitHub 登录并授权。授权后浏览器会跳到 ${cid.origin} 下的一个地址,` +
|
|
1098
|
+
"它显示的是「页面未找到」——这是正常的,那一页不会用掉你的授权码。" +
|
|
1099
|
+
"请把地址栏里的完整地址(含 code=… 和 state=…)复制下来,回到这里粘贴;也可仅粘贴 code。\n" +
|
|
1100
|
+
"这个链接只完成站点登录,不会自动读取或弹出 GitHub Cookie 获取窗口;Cookie 是以后自动签到的可选配置。\n" +
|
|
1101
|
+
"授权码是一次性的;若已使用或过期失效,请重新发起登录,不要重复提交旧地址。若 GitHub 显示回调地址未注册/redirect_uri mismatch,请联系站点管理员;这不是 Cookie 或二次验证的问题。",
|
|
1102
|
+
method: "code",
|
|
1103
|
+
// The code is read from whatever the user pastes: the whole
|
|
1104
|
+
// callback URL, or just the code.
|
|
1105
|
+
callback: async (pasted) => {
|
|
1106
|
+
const code = githubCallbackCode(pasted, callbackUrl(cid.origin), st.state);
|
|
1107
|
+
if (!code) return { type: "failed", error: "GitHub 回调地址、state 或授权码无效,或授权已取消;请重新登录并复制完整回调地址或仅 code" };
|
|
1108
|
+
return redeemGithub(site.site, code, st.state, { ...inputs, githubCookie });
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
},
|
|
1112
|
+
prompts: [
|
|
1113
|
+
{
|
|
1114
|
+
type: "select",
|
|
1115
|
+
key: "site",
|
|
1116
|
+
message: "站点",
|
|
1117
|
+
options: [
|
|
1118
|
+
{ label: "agentrouter.org", value: "org" },
|
|
1119
|
+
{ label: "ps.air-outer.com(备用域名)", value: "air" },
|
|
1120
|
+
],
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
type: "password",
|
|
1124
|
+
key: "accessToken",
|
|
1125
|
+
message: "系统访问令牌(必填,AgentRouter「个人设置 → 访问令牌」;不是 API 密钥或 GitHub Token)",
|
|
1126
|
+
placeholder: "粘贴系统访问令牌,用于查询账号额度",
|
|
1127
|
+
},
|
|
1128
|
+
...githubCookiePrompts(),
|
|
1129
|
+
],
|
|
1130
|
+
},
|
|
1131
|
+
],
|
|
1132
|
+
|
|
1133
|
+
// Inline replay avoids resource-local item lookups when complete contents
|
|
1134
|
+
// are present. Server-only references and unknown kinds stay untouched.
|
|
1135
|
+
// Response normalization preserves successful bytes and never retries.
|
|
1136
|
+
async loader(getAuth, provider) {
|
|
1137
|
+
const auth = await getAuth();
|
|
1138
|
+
if (!auth) return {};
|
|
1139
|
+
const textFilter = textFilterEnabled() ? await loadTextFilter() : null;
|
|
1140
|
+
const base = apiBase(provider, auth);
|
|
1141
|
+
return {
|
|
1142
|
+
baseURL: base,
|
|
1143
|
+
apiKey: apiKey(auth) || "agentrouter",
|
|
1144
|
+
async fetch(input, init = {}) {
|
|
1145
|
+
const key = apiKey(auth);
|
|
1146
|
+
const h = new Headers(input instanceof Request ? input.headers : undefined);
|
|
1147
|
+
new Headers(init.headers ?? {}).forEach((v, k) => h.set(k, v));
|
|
1148
|
+
applyHeaders(h, headersOf(provider, auth));
|
|
1149
|
+
if (key) h.set("Authorization", `Bearer ${key}`);
|
|
1150
|
+
const requestInit = { ...init, headers: h };
|
|
1151
|
+
const prepared = await prepareRequest(input, requestInit, {
|
|
1152
|
+
portableResponses: portableResponses(),
|
|
1153
|
+
textFilter: textFilterEnabled() ? textFilter : null,
|
|
1154
|
+
});
|
|
1155
|
+
return explainResponse(await fetch(input, prepared));
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
},
|
|
1159
|
+
|
|
1160
|
+
usage: usageOf,
|
|
1161
|
+
checkin: checkinOf,
|
|
1162
|
+
|
|
1163
|
+
// Switches the user can turn on the provider's editor.
|
|
1164
|
+
options: [
|
|
1165
|
+
{
|
|
1166
|
+
key: "text-filter",
|
|
1167
|
+
label: "Filter configured prose before sending",
|
|
1168
|
+
default: true,
|
|
1169
|
+
description: "Uses content-filter.local.json; no guessed terms. Preserves recognized Markdown code, tool data and encrypted state. Reload after editing rules.",
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
key: "portable-responses",
|
|
1173
|
+
label: "Replay complete Responses items inline",
|
|
1174
|
+
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.",
|
|
1175
|
+
default: true,
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
key: "send-codex-headers",
|
|
1179
|
+
label: "Send the Codex client headers",
|
|
1180
|
+
description:
|
|
1181
|
+
"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.",
|
|
1182
|
+
default: true,
|
|
1183
|
+
},
|
|
1184
|
+
],
|
|
1185
|
+
},
|
|
1186
|
+
|
|
1187
|
+
provider: {
|
|
1188
|
+
id: PROVIDER,
|
|
1189
|
+
models: modelsOf,
|
|
1190
|
+
},
|
|
1191
|
+
|
|
1192
|
+
// A conversation's turns reach the vendor under one id, so its cache sees
|
|
1193
|
+
// them as one conversation.
|
|
1194
|
+
"chat.headers": async (input, output) => {
|
|
1195
|
+
if (input?.model?.providerID !== PROVIDER && input?.provider?.info?.id !== PROVIDER) return;
|
|
1196
|
+
if (input.sessionID) output.headers["x-session-id"] = input.sessionID;
|
|
1197
|
+
},
|
|
1198
|
+
};
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
export default AgentRouterAuthPlugin;
|
|
1202
|
+
export const _internal = {
|
|
1203
|
+
PROVIDER,
|
|
1204
|
+
SITES,
|
|
1205
|
+
DEFAULT_HEADERS,
|
|
1206
|
+
DECLARED_MODELS,
|
|
1207
|
+
parseHeaders,
|
|
1208
|
+
parseCookies,
|
|
1209
|
+
codeOf,
|
|
1210
|
+
headersOf,
|
|
1211
|
+
applyHeaders,
|
|
1212
|
+
apiBase,
|
|
1213
|
+
siteBase,
|
|
1214
|
+
userHeaders,
|
|
1215
|
+
masked,
|
|
1216
|
+
isURLish,
|
|
1217
|
+
declaredModels,
|
|
1218
|
+
};
|