@d-ai/magpie 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -10
- package/index.mjs +349 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,16 +13,23 @@ One `ddsk_…` API key is one account, and magpie keeps several.
|
|
|
13
13
|
magpie plugin add mdddj/d-ai-magpie # from GitHub
|
|
14
14
|
magpie plugin add @d-ai/magpie # from npm, once published
|
|
15
15
|
magpie plugin add ./sdks/d-ai-magpie # from a folder, for development
|
|
16
|
-
magpie plugin login dai
|
|
16
|
+
magpie plugin login dai oauth # sign in with a browser
|
|
17
|
+
magpie plugin login dai # or choose the API key method
|
|
17
18
|
magpie provider test dai # one tiny call per API
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
Then pick a model anywhere magpie does: `dai/claude-opus-5-5`,
|
|
21
22
|
`dai/gpt-6-astra`, `dai/deepseek-v4.1-flash`, …
|
|
22
23
|
|
|
23
|
-
The
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
The browser sign-in uses OAuth 2.1 authorization code with PKCE S256 and a
|
|
25
|
+
loopback callback on a random `127.0.0.1` port, so no fixed port is reserved and
|
|
26
|
+
the refresh token stays in magpie's auth store. It uses the first-party public
|
|
27
|
+
client `ddc_i4mhCndrFLK3LAYBaK8d` by default. The API key remains available as
|
|
28
|
+
the fallback; keys come from the D.AI console (`/#/apikeys`). Custom deployments
|
|
29
|
+
can point at another host with
|
|
30
|
+
`magpie plugin options @d-ai/magpie@latest '{"baseUrl":"https://dai.example"}'`
|
|
31
|
+
or `DAI_BASE_URL`. `plugin options` takes the installed plugin spec, not the
|
|
32
|
+
provider id (`dai`).
|
|
26
33
|
|
|
27
34
|
## Chat models
|
|
28
35
|
|
|
@@ -76,17 +83,29 @@ Paid generation and account-scoped job tools are intentionally not included.
|
|
|
76
83
|
Magpie's tool process does not receive the signed-in `dai` provider's API key,
|
|
77
84
|
so adding generation here would require a separate tool-auth design.
|
|
78
85
|
|
|
86
|
+
For a deployment with its own OAuth registration, add an `oauthClientId` option
|
|
87
|
+
next to `baseUrl`; the default remains the first-party
|
|
88
|
+
`ddc_i4mhCndrFLK3LAYBaK8d`. A custom client must register a loopback callback
|
|
89
|
+
for `http://127.0.0.1:<random port>/callback`, which the deployment's
|
|
90
|
+
authorization server accepts under RFC 8252.
|
|
91
|
+
|
|
79
92
|
## Usage
|
|
80
93
|
|
|
81
|
-
`magpie quota` shows the account behind
|
|
82
|
-
reservation, the tier and its cycle, included USD, the key's own
|
|
83
|
-
daily limits, and how many calls it made today. That
|
|
84
|
-
`GET /api/ai/account`,
|
|
94
|
+
`magpie quota` shows the account behind either sign-in: the wallet balance and
|
|
95
|
+
pending reservation, the tier and its cycle, included USD, the API key's own
|
|
96
|
+
reservation and daily limits (if any), and how many calls it made today. That
|
|
97
|
+
comes from `GET /api/ai/account`, which accepts both a `ddsk_` API key and an
|
|
98
|
+
OAuth access token carrying the `media` scope. OAuth has no key envelope or
|
|
99
|
+
key-level daily limits; it spends the same shared wallet.
|
|
85
100
|
|
|
86
101
|
A D.AI key has no plan window — it spends the account's shared wallet — so the
|
|
87
102
|
card reads as a balance, not a quota window. Two keys of one account show the
|
|
88
103
|
same balance but their own reservations and call counts.
|
|
89
104
|
|
|
105
|
+
Magpie renews OAuth access tokens before they expire through `auth.refresh`;
|
|
106
|
+
the rotated refresh token is written back to the same auth store. OAuth requests
|
|
107
|
+
send only `Authorization: Bearer <token>` and never `x-api-key`.
|
|
108
|
+
|
|
90
109
|
## Notes
|
|
91
110
|
|
|
92
111
|
- Chat models use the provider API. Image, video and audio models are separate
|
|
@@ -99,6 +118,7 @@ same balance but their own reservations and call counts.
|
|
|
99
118
|
counts instead of a balance.
|
|
100
119
|
- The plugin never rewrites request bodies. Thinking levels are offered as
|
|
101
120
|
model variants, and the gateway passes `reasoning_effort` through.
|
|
102
|
-
-
|
|
103
|
-
|
|
121
|
+
- API-key requests go out with `Authorization: Bearer <key>` plus `x-api-key`;
|
|
122
|
+
OAuth requests send only the bearer token. Both carry
|
|
123
|
+
`x-dai-client: magpie/0.1.4`. Gateway requests also carry the stable Magpie
|
|
104
124
|
session as `x-dai-session`.
|
package/index.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// D.AI (dai.itbug.shop) as a magpie / OpenCode provider plugin.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// under the same provider. Models come from D.AI's public capability catalog
|
|
3
|
+
// A D.AI OAuth sign-in or API key (`ddsk_…`) signs an account in; magpie may
|
|
4
|
+
// hold several under the same provider. Models come from D.AI's public capability catalog
|
|
5
5
|
// (`GET /api/system/public/capability-docs`), narrowed to what the key may
|
|
6
6
|
// actually call (`GET /v1/models`). Chat models run on D.AI's
|
|
7
7
|
// OpenAI-compatible `/v1/chat/completions`, Claude models on `/v1/messages`.
|
|
8
8
|
// `auth.usage` reads the account behind the key (`GET /api/ai/account`): the
|
|
9
9
|
// wallet balance, the tier, and how much this key alone may spend.
|
|
10
10
|
//
|
|
11
|
-
// `auth.usage` reads `GET /api/ai/account`, a `ddsk_
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// `auth.usage` reads `GET /api/ai/account`, which accepts both a `ddsk_` key
|
|
12
|
+
// and an OAuth access token with the `media` scope. The older JWT-only
|
|
13
|
+
// endpoints (`/api/usd/me`, `/api/user/usage-stats`) cannot serve a plugin,
|
|
14
|
+
// and the key's call log only counts calls, not money.
|
|
15
15
|
//
|
|
16
16
|
// Facts the tables below encode (checked 2026-10-22):
|
|
17
17
|
// - The capability codes carrying token-metered chat are
|
|
@@ -26,14 +26,21 @@
|
|
|
26
26
|
// until someone checks it. D.AI's own catalog is not consulted for either:
|
|
27
27
|
// it carries no per-model reasoning or modality metadata.
|
|
28
28
|
//
|
|
29
|
+
// OAuth uses the system browser with PKCE S256 and a loopback callback owned by
|
|
30
|
+
// this plugin. The refresh token stays in magpie's auth store; access tokens
|
|
31
|
+
// and their expiry are renewed by magpie before every request.
|
|
32
|
+
//
|
|
29
33
|
// The same module also gives a read-only media catalog tool. Magpie tools run
|
|
30
34
|
// from a separate MCP server and do not receive the provider account's key, so
|
|
31
35
|
// paid generation and account-scoped job tools are intentionally absent.
|
|
32
36
|
//
|
|
33
37
|
// Sections: constants · helpers · chat catalog · media catalog · plugin.
|
|
34
38
|
|
|
39
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
40
|
+
import { createServer } from "node:http";
|
|
41
|
+
|
|
35
42
|
const PROVIDER = "dai";
|
|
36
|
-
const CLIENT = "magpie/0.1.
|
|
43
|
+
const CLIENT = "magpie/0.1.4";
|
|
37
44
|
const DEFAULT_BASE = "https://dai.itbug.shop";
|
|
38
45
|
const OPENAI_NPM = "@ai-sdk/openai-compatible";
|
|
39
46
|
const ANTHROPIC_NPM = "@ai-sdk/anthropic";
|
|
@@ -41,6 +48,10 @@ const CATALOG_TTL_MS = 5 * 60 * 1000;
|
|
|
41
48
|
const REQUEST_TIMEOUT_MS = 20_000;
|
|
42
49
|
const MEDIA_CATALOG_LIMIT = 20;
|
|
43
50
|
const MEDIA_KINDS = new Set(["image", "video", "audio"]);
|
|
51
|
+
const OAUTH_CLIENT_ID = "ddc_i4mhCndrFLK3LAYBaK8d";
|
|
52
|
+
const OAUTH_SCOPES = "openid profile email offline_access media";
|
|
53
|
+
const OAUTH_CALLBACK_PATH = "/callback";
|
|
54
|
+
const OAUTH_SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
44
55
|
|
|
45
56
|
/** Display names for the public tiers; unknown codes stay unchanged. */
|
|
46
57
|
const TIER_NAMES = {
|
|
@@ -48,7 +59,7 @@ const TIER_NAMES = {
|
|
|
48
59
|
basic: "基础版",
|
|
49
60
|
pro: "专业版",
|
|
50
61
|
max: "旗舰版",
|
|
51
|
-
trial: "
|
|
62
|
+
trial: "试用用户",
|
|
52
63
|
};
|
|
53
64
|
|
|
54
65
|
/** Billing cycles as the usage card says them. */
|
|
@@ -155,6 +166,18 @@ function keyLabel(key) {
|
|
|
155
166
|
return id ? `密钥 ${id}` : "API 密钥";
|
|
156
167
|
}
|
|
157
168
|
|
|
169
|
+
const isOAuthAuth = (auth) => auth?.type === "oauth";
|
|
170
|
+
|
|
171
|
+
/** The credential a signed-in account uses for D.AI requests. */
|
|
172
|
+
const credentialOf = (auth) =>
|
|
173
|
+
isOAuthAuth(auth) ? textOf(auth.access) : textOf(auth?.key);
|
|
174
|
+
|
|
175
|
+
/** A label for a card when the account has no usable key prefix. */
|
|
176
|
+
function authLabel(auth) {
|
|
177
|
+
if (!isOAuthAuth(auth)) return keyLabel(auth?.key);
|
|
178
|
+
return textOf(auth.accountId) || "OAuth 登录";
|
|
179
|
+
}
|
|
180
|
+
|
|
158
181
|
const tierLabel = (code) => TIER_NAMES[textOf(code)] ?? textOf(code);
|
|
159
182
|
const cycleLabel = (cycle) => BILLING_CYCLES[textOf(cycle)] ?? textOf(cycle);
|
|
160
183
|
const statusLabel = (status) => {
|
|
@@ -261,7 +284,10 @@ function fallbackCatalog(base) {
|
|
|
261
284
|
|
|
262
285
|
// ---- chat catalog --------------------------------------------------------------
|
|
263
286
|
|
|
264
|
-
async function fetchJson(
|
|
287
|
+
async function fetchJson(
|
|
288
|
+
url,
|
|
289
|
+
{ apiKey, oauth = false, signal, headers: extraHeaders } = {},
|
|
290
|
+
) {
|
|
265
291
|
const controller = new AbortController();
|
|
266
292
|
const onAbort = () => controller.abort();
|
|
267
293
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
@@ -270,7 +296,7 @@ async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
|
|
|
270
296
|
headers.set("x-dai-client", CLIENT);
|
|
271
297
|
if (apiKey) {
|
|
272
298
|
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
273
|
-
headers.set("x-api-key", apiKey);
|
|
299
|
+
if (!oauth) headers.set("x-api-key", apiKey);
|
|
274
300
|
}
|
|
275
301
|
try {
|
|
276
302
|
const res = await fetch(url, { headers, signal: controller.signal });
|
|
@@ -284,6 +310,259 @@ async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
|
|
|
284
310
|
}
|
|
285
311
|
}
|
|
286
312
|
|
|
313
|
+
// ---- OAuth ---------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function pkcePair() {
|
|
316
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
317
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
318
|
+
return { verifier, challenge };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function oauthForm(values) {
|
|
322
|
+
const body = new URLSearchParams();
|
|
323
|
+
for (const [key, value] of Object.entries(values)) {
|
|
324
|
+
if (value !== undefined && value !== null && String(value) !== "") {
|
|
325
|
+
body.set(key, String(value));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return body;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function tokenRequest(base, values, signal) {
|
|
332
|
+
const res = await fetch(`${base}/api/oauth/token`, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: {
|
|
335
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
336
|
+
accept: "application/json",
|
|
337
|
+
"x-dai-client": CLIENT,
|
|
338
|
+
},
|
|
339
|
+
body: oauthForm(values),
|
|
340
|
+
signal: signal
|
|
341
|
+
? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
|
|
342
|
+
: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
343
|
+
});
|
|
344
|
+
const payload = await res.json().catch(() => null);
|
|
345
|
+
if (!res.ok) {
|
|
346
|
+
return {
|
|
347
|
+
status: res.status,
|
|
348
|
+
error: textOf(payload?.error),
|
|
349
|
+
description: textOf(payload?.error_description),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return payload ?? {};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A loopback callback for one OAuth sign-in. Binding to port 0 keeps the
|
|
357
|
+
* callback flexible without reserving a fixed port on the user's Mac.
|
|
358
|
+
*/
|
|
359
|
+
function callbackServer() {
|
|
360
|
+
return new Promise((resolve, reject) => {
|
|
361
|
+
const server = createServer();
|
|
362
|
+
const sockets = new Set();
|
|
363
|
+
const waiters = new Set();
|
|
364
|
+
let accepted = false;
|
|
365
|
+
let settled = false;
|
|
366
|
+
let closed = false;
|
|
367
|
+
const settle = (query) => {
|
|
368
|
+
if (settled) return false;
|
|
369
|
+
settled = true;
|
|
370
|
+
for (const waiter of waiters) waiter(query);
|
|
371
|
+
waiters.clear();
|
|
372
|
+
return true;
|
|
373
|
+
};
|
|
374
|
+
const close = () => {
|
|
375
|
+
if (closed) return;
|
|
376
|
+
closed = true;
|
|
377
|
+
server.close(() => {
|
|
378
|
+
for (const socket of sockets) socket.destroy();
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
server.on("connection", (socket) => {
|
|
382
|
+
sockets.add(socket);
|
|
383
|
+
socket.on("close", () => sockets.delete(socket));
|
|
384
|
+
});
|
|
385
|
+
server.on("error", reject);
|
|
386
|
+
server.on("request", (req, res) => {
|
|
387
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
388
|
+
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
|
389
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
390
|
+
res.end("not found");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (accepted) {
|
|
394
|
+
res.writeHead(409, { "content-type": "text/plain; charset=utf-8" });
|
|
395
|
+
res.end("callback already received");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
accepted = true;
|
|
399
|
+
res.writeHead(200, {
|
|
400
|
+
"content-type": "text/html; charset=utf-8",
|
|
401
|
+
"cache-control": "no-store",
|
|
402
|
+
});
|
|
403
|
+
res.end(
|
|
404
|
+
'<!doctype html><meta charset="utf-8"><title>D.AI 回调</title><p>回调已收到,请返回 Magpie 查看结果。</p>',
|
|
405
|
+
() => {
|
|
406
|
+
settle(url.searchParams);
|
|
407
|
+
close();
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
server.listen(0, "127.0.0.1", () => {
|
|
412
|
+
const address = server.address();
|
|
413
|
+
if (!address || typeof address === "string") {
|
|
414
|
+
server.close();
|
|
415
|
+
reject(new Error("无法创建 D.AI 本地回调"));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const redirectUri = `http://127.0.0.1:${address.port}${OAUTH_CALLBACK_PATH}`;
|
|
419
|
+
resolve({
|
|
420
|
+
redirectUri,
|
|
421
|
+
wait({ signal, timeoutMs = OAUTH_SIGN_IN_TIMEOUT_MS } = {}) {
|
|
422
|
+
return new Promise((done, fail) => {
|
|
423
|
+
if (signal?.aborted) {
|
|
424
|
+
close();
|
|
425
|
+
fail(new Error("D.AI 登录已取消"));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
let finished = false;
|
|
429
|
+
let timer;
|
|
430
|
+
const cleanup = () => {
|
|
431
|
+
if (timer) clearTimeout(timer);
|
|
432
|
+
waiters.delete(waiter);
|
|
433
|
+
signal?.removeEventListener("abort", onAbort);
|
|
434
|
+
};
|
|
435
|
+
const finish = (ok, value) => {
|
|
436
|
+
if (finished) return;
|
|
437
|
+
finished = true;
|
|
438
|
+
cleanup();
|
|
439
|
+
if (ok) done(value);
|
|
440
|
+
else fail(value);
|
|
441
|
+
};
|
|
442
|
+
const onAbort = () => {
|
|
443
|
+
close();
|
|
444
|
+
finish(false, new Error("D.AI 登录已取消"));
|
|
445
|
+
};
|
|
446
|
+
const waiter = (query) => {
|
|
447
|
+
finish(true, { query });
|
|
448
|
+
};
|
|
449
|
+
waiters.add(waiter);
|
|
450
|
+
timer = setTimeout(() => {
|
|
451
|
+
close();
|
|
452
|
+
finish(false, new Error("D.AI 登录超时,请重新登录"));
|
|
453
|
+
}, timeoutMs);
|
|
454
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
close,
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Browser sign-in: authorization code + PKCE, callback owned by the plugin. */
|
|
464
|
+
async function browserSignIn(base, clientId = OAUTH_CLIENT_ID) {
|
|
465
|
+
const { verifier, challenge } = pkcePair();
|
|
466
|
+
const state = randomBytes(16).toString("base64url");
|
|
467
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
468
|
+
const callback = await callbackServer();
|
|
469
|
+
const authorize = new URL(`${base}/api/oauth/authorize`);
|
|
470
|
+
authorize.searchParams.set("client_id", clientId);
|
|
471
|
+
authorize.searchParams.set("redirect_uri", callback.redirectUri);
|
|
472
|
+
authorize.searchParams.set("response_type", "code");
|
|
473
|
+
authorize.searchParams.set("scope", OAUTH_SCOPES);
|
|
474
|
+
authorize.searchParams.set("state", state);
|
|
475
|
+
authorize.searchParams.set("nonce", nonce);
|
|
476
|
+
authorize.searchParams.set("code_challenge", challenge);
|
|
477
|
+
authorize.searchParams.set("code_challenge_method", "S256");
|
|
478
|
+
return {
|
|
479
|
+
url: authorize.toString(),
|
|
480
|
+
instructions: "在浏览器中登录 D.AI 并同意授权;完成后会自动回到 Magpie。",
|
|
481
|
+
method: "auto",
|
|
482
|
+
async callback({ signal, timeoutMs = OAUTH_SIGN_IN_TIMEOUT_MS } = {}) {
|
|
483
|
+
try {
|
|
484
|
+
const { query } = await callback.wait({ signal, timeoutMs });
|
|
485
|
+
if (textOf(query.get("state")) !== state) {
|
|
486
|
+
return { type: "failed", error: "D.AI 登录状态校验失败" };
|
|
487
|
+
}
|
|
488
|
+
const error = textOf(query.get("error"));
|
|
489
|
+
if (error) {
|
|
490
|
+
return {
|
|
491
|
+
type: "failed",
|
|
492
|
+
error: textOf(query.get("error_description")) || `D.AI 登录失败:${error}`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const code = textOf(query.get("code"));
|
|
496
|
+
if (!code) return { type: "failed", error: "D.AI 没有返回授权码" };
|
|
497
|
+
const token = await tokenRequest(
|
|
498
|
+
base,
|
|
499
|
+
{
|
|
500
|
+
grant_type: "authorization_code",
|
|
501
|
+
client_id: clientId,
|
|
502
|
+
code,
|
|
503
|
+
redirect_uri: callback.redirectUri,
|
|
504
|
+
code_verifier: verifier,
|
|
505
|
+
},
|
|
506
|
+
signal,
|
|
507
|
+
);
|
|
508
|
+
if (!token.access_token) {
|
|
509
|
+
return {
|
|
510
|
+
type: "failed",
|
|
511
|
+
error:
|
|
512
|
+
token.description ||
|
|
513
|
+
token.error ||
|
|
514
|
+
`D.AI 换取令牌失败(HTTP ${token.status ?? "?"})`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const who = await fetchJson(`${base}/api/oauth/userinfo`, {
|
|
518
|
+
apiKey: token.access_token,
|
|
519
|
+
oauth: true,
|
|
520
|
+
}).catch(() => null);
|
|
521
|
+
const accountId =
|
|
522
|
+
textOf(who?.email) || textOf(who?.preferred_username) || textOf(who?.sub);
|
|
523
|
+
return {
|
|
524
|
+
type: "success",
|
|
525
|
+
access: token.access_token,
|
|
526
|
+
refresh: token.refresh_token ?? "",
|
|
527
|
+
expires: Date.now() + numberOr(token.expires_in, 3600) * 1000,
|
|
528
|
+
...(accountId ? { accountId } : {}),
|
|
529
|
+
...(textOf(who?.sub) ? { uid: textOf(who.sub) } : {}),
|
|
530
|
+
};
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return { type: "failed", error: String(error?.message ?? error) };
|
|
533
|
+
} finally {
|
|
534
|
+
callback.close();
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function refreshOAuth(base, auth, clientId = OAUTH_CLIENT_ID) {
|
|
541
|
+
if (!isOAuthAuth(auth)) return;
|
|
542
|
+
const refresh = textOf(auth?.refresh);
|
|
543
|
+
if (!refresh) throw Object.assign(new Error("D.AI 登录已失效"), { signIn: "expired" });
|
|
544
|
+
const token = await tokenRequest(base, {
|
|
545
|
+
grant_type: "refresh_token",
|
|
546
|
+
client_id: clientId,
|
|
547
|
+
refresh_token: refresh,
|
|
548
|
+
});
|
|
549
|
+
if (!token.access_token) {
|
|
550
|
+
const expired =
|
|
551
|
+
token.status === 400 ||
|
|
552
|
+
token.error === "invalid_grant" ||
|
|
553
|
+
token.error === "invalid_refresh_token";
|
|
554
|
+
throw Object.assign(
|
|
555
|
+
new Error(token.description || token.error || "D.AI 刷新登录失败"),
|
|
556
|
+
expired ? { signIn: "expired" } : {},
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
access: token.access_token,
|
|
561
|
+
refresh: token.refresh_token ?? refresh,
|
|
562
|
+
expires: Date.now() + numberOr(token.expires_in, 3600) * 1000,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
287
566
|
/**
|
|
288
567
|
* Every chat model D.AI publishes, keyed by model id. With a key the catalog is
|
|
289
568
|
* narrowed to the models that key may call; a `/v1/models` failure leaves the
|
|
@@ -294,7 +573,11 @@ async function fetchCatalog(base, { key, signal } = {}) {
|
|
|
294
573
|
const models = chatModelsFromDocs(docs, base);
|
|
295
574
|
if (!key || !Object.keys(models).length) return models;
|
|
296
575
|
try {
|
|
297
|
-
const listed = await fetchJson(`${apiBase(base)}/models`, {
|
|
576
|
+
const listed = await fetchJson(`${apiBase(base)}/models`, {
|
|
577
|
+
apiKey: key,
|
|
578
|
+
oauth: key.startsWith("ddoa_"),
|
|
579
|
+
signal,
|
|
580
|
+
});
|
|
298
581
|
const allowed = new Set(
|
|
299
582
|
(listed?.data ?? []).map((model) => textOf(model?.id)).filter(Boolean),
|
|
300
583
|
);
|
|
@@ -304,7 +587,7 @@ async function fetchCatalog(base, { key, signal } = {}) {
|
|
|
304
587
|
// A refused key must reach Magpie as a sign-in failure. A network or
|
|
305
588
|
// server failure keeps the last good catalog instead.
|
|
306
589
|
if (error?.status === 401 || error?.status === 403) {
|
|
307
|
-
throw Object.assign(new Error("D.AI
|
|
590
|
+
throw Object.assign(new Error("D.AI 登录已失效,请重新登录"), {
|
|
308
591
|
status: error.status,
|
|
309
592
|
signIn: "expired",
|
|
310
593
|
});
|
|
@@ -378,7 +661,7 @@ function balanceCard(account, key) {
|
|
|
378
661
|
parts.push(`今日 ${calls} 次调用`);
|
|
379
662
|
}
|
|
380
663
|
return {
|
|
381
|
-
plan: tierLabel(tier.code) ||
|
|
664
|
+
plan: tierLabel(tier.code) || key || "D.AI 账号",
|
|
382
665
|
balance: total === undefined ? "" : `$${usd(total)}`,
|
|
383
666
|
windows: [{ name: "D.AI 余额", display: parts.join(" · ") }],
|
|
384
667
|
};
|
|
@@ -566,6 +849,7 @@ async function mediaCatalog(base, input, signal) {
|
|
|
566
849
|
|
|
567
850
|
const DaiPlugin = async (_input, options = {}) => {
|
|
568
851
|
const base = normalizeBase(options?.baseUrl ?? process.env.DAI_BASE_URL) ?? DEFAULT_BASE;
|
|
852
|
+
const oauthClientId = textOf(options?.oauthClientId) || OAUTH_CLIENT_ID;
|
|
569
853
|
const offline = fallbackCatalog(base);
|
|
570
854
|
|
|
571
855
|
// One catalog read per account, reused until it goes stale. Keyed by the
|
|
@@ -599,43 +883,70 @@ const DaiPlugin = async (_input, options = {}) => {
|
|
|
599
883
|
auth: {
|
|
600
884
|
provider: PROVIDER,
|
|
601
885
|
|
|
602
|
-
|
|
886
|
+
refreshLead: 5 * 60 * 1000,
|
|
887
|
+
|
|
888
|
+
async refresh(auth) {
|
|
889
|
+
return refreshOAuth(base, auth, oauthClientId);
|
|
890
|
+
},
|
|
891
|
+
|
|
892
|
+
methods: [
|
|
893
|
+
{
|
|
894
|
+
type: "oauth",
|
|
895
|
+
label: "使用 D.AI 账号登录",
|
|
896
|
+
authorize: () => browserSignIn(base, oauthClientId),
|
|
897
|
+
},
|
|
898
|
+
{ type: "api", label: "D.AI API 密钥", placeholder: "ddsk_…" },
|
|
899
|
+
],
|
|
603
900
|
|
|
604
901
|
async loader(getAuth) {
|
|
605
902
|
const auth = await getAuth();
|
|
606
|
-
const
|
|
607
|
-
if (
|
|
903
|
+
const credential = credentialOf(auth);
|
|
904
|
+
if (!credential) return {};
|
|
905
|
+
if (isOAuthAuth(auth)) {
|
|
906
|
+
return {
|
|
907
|
+
baseURL: apiBase(base),
|
|
908
|
+
apiKey: credential,
|
|
909
|
+
headers: { "x-dai-client": CLIENT },
|
|
910
|
+
fetch(input, init) {
|
|
911
|
+
const headers = new Headers(init?.headers);
|
|
912
|
+
headers.delete("x-api-key");
|
|
913
|
+
headers.set("authorization", `Bearer ${credential}`);
|
|
914
|
+
return fetch(input, { ...init, headers });
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
}
|
|
608
918
|
return {
|
|
609
919
|
baseURL: apiBase(base),
|
|
610
|
-
apiKey:
|
|
920
|
+
apiKey: credential,
|
|
611
921
|
headers: { "x-dai-client": CLIENT },
|
|
612
922
|
};
|
|
613
923
|
},
|
|
614
924
|
|
|
615
|
-
// The account behind
|
|
616
|
-
// from
|
|
617
|
-
//
|
|
618
|
-
// cannot otherwise read. The card says the balance as the wallet's
|
|
619
|
-
// total, then how much of it this key may spend and what it has used.
|
|
925
|
+
// The account behind either sign-in: wallet balance, tier and token
|
|
926
|
+
// totals from `GET /api/ai/account`. The card says the balance as the
|
|
927
|
+
// wallet's total, then what this key may spend and what it has used.
|
|
620
928
|
async usage(getAuth) {
|
|
621
929
|
const auth = await getAuth();
|
|
622
|
-
const
|
|
623
|
-
if (
|
|
624
|
-
return { windows: [], error: "D.AI
|
|
930
|
+
const credential = credentialOf(auth);
|
|
931
|
+
if (!credential) {
|
|
932
|
+
return { windows: [], error: "D.AI:请先登录" };
|
|
625
933
|
}
|
|
626
934
|
try {
|
|
627
|
-
const account = await fetchJson(`${base}/api/ai/account`, {
|
|
628
|
-
|
|
935
|
+
const account = await fetchJson(`${base}/api/ai/account`, {
|
|
936
|
+
apiKey: credential,
|
|
937
|
+
oauth: isOAuthAuth(auth),
|
|
938
|
+
});
|
|
939
|
+
return balanceCard(account, authLabel(auth));
|
|
629
940
|
} catch (error) {
|
|
630
941
|
const status = error?.status;
|
|
631
942
|
if (status === 401 || status === 403) {
|
|
632
|
-
return { windows: [], error: "D.AI
|
|
943
|
+
return { windows: [], error: "D.AI 登录已失效", signIn: "expired" };
|
|
633
944
|
}
|
|
634
945
|
// A deployment older than the endpoint answers 404. Its call log
|
|
635
946
|
// is still there, and counting calls beats showing an error.
|
|
636
|
-
if (status === 404) {
|
|
947
|
+
if (status === 404 && !isOAuthAuth(auth)) {
|
|
637
948
|
try {
|
|
638
|
-
return await callLogCard(base,
|
|
949
|
+
return await callLogCard(base, credential);
|
|
639
950
|
} catch (fallbackError) {
|
|
640
951
|
return { windows: [], error: String(fallbackError?.message ?? fallbackError) };
|
|
641
952
|
}
|
|
@@ -651,10 +962,10 @@ const DaiPlugin = async (_input, options = {}) => {
|
|
|
651
962
|
// The account's own list when D.AI answers, else the catalog the
|
|
652
963
|
// config hook declared, so a failed fetch never empties the provider.
|
|
653
964
|
async models(provider, { auth } = {}) {
|
|
654
|
-
const
|
|
655
|
-
if (!
|
|
965
|
+
const credential = credentialOf(auth);
|
|
966
|
+
if (!credential) return provider.models;
|
|
656
967
|
try {
|
|
657
|
-
const models = await catalogFor(
|
|
968
|
+
const models = await catalogFor(credential, undefined);
|
|
658
969
|
return models ?? provider.models;
|
|
659
970
|
} catch (error) {
|
|
660
971
|
if (error?.signIn === "expired") throw error;
|
|
@@ -721,8 +1032,10 @@ export default { id: PROVIDER, server: DaiPlugin };
|
|
|
721
1032
|
|
|
722
1033
|
/** For tests and manual probing: the pure pieces, never called as a plugin. */
|
|
723
1034
|
export const _internal = {
|
|
1035
|
+
authLabel,
|
|
724
1036
|
balanceCard,
|
|
725
1037
|
buildModel,
|
|
1038
|
+
browserSignIn,
|
|
726
1039
|
fallbackCatalog,
|
|
727
1040
|
fetchCatalog,
|
|
728
1041
|
fetchGatewayModels,
|
|
@@ -733,4 +1046,7 @@ export const _internal = {
|
|
|
733
1046
|
mediaModel,
|
|
734
1047
|
mediaRows,
|
|
735
1048
|
normalizeBase,
|
|
1049
|
+
pkcePair,
|
|
1050
|
+
refreshOAuth,
|
|
1051
|
+
tokenRequest,
|
|
736
1052
|
};
|
package/package.json
CHANGED