@d-ai/magpie 0.1.2 → 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 +399 -48
- 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,27 @@ 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;
|
|
55
|
+
|
|
56
|
+
/** Display names for the public tiers; unknown codes stay unchanged. */
|
|
57
|
+
const TIER_NAMES = {
|
|
58
|
+
starter: "入门版",
|
|
59
|
+
basic: "基础版",
|
|
60
|
+
pro: "专业版",
|
|
61
|
+
max: "旗舰版",
|
|
62
|
+
trial: "试用用户",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Billing cycles as the usage card says them. */
|
|
66
|
+
const BILLING_CYCLES = {
|
|
67
|
+
monthly: "月付",
|
|
68
|
+
quarterly: "季付",
|
|
69
|
+
semiannual: "半年付",
|
|
70
|
+
annual: "年付",
|
|
71
|
+
};
|
|
44
72
|
|
|
45
73
|
/** Capability codes whose models are token-metered chat models. */
|
|
46
74
|
const CHAT_CAPABILITIES = new Set(["openai_chat_completions", "claude_messages"]);
|
|
@@ -135,9 +163,39 @@ const isClaudeModel = (id) => id.startsWith("claude-");
|
|
|
135
163
|
/** `ddsk_01ABC_secret` → what a card may show, never the secret itself. */
|
|
136
164
|
function keyLabel(key) {
|
|
137
165
|
const id = textOf(key).replace(/^ddsk_/u, "").split("_")[0];
|
|
138
|
-
return id ?
|
|
166
|
+
return id ? `密钥 ${id}` : "API 密钥";
|
|
139
167
|
}
|
|
140
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
|
+
|
|
181
|
+
const tierLabel = (code) => TIER_NAMES[textOf(code)] ?? textOf(code);
|
|
182
|
+
const cycleLabel = (cycle) => BILLING_CYCLES[textOf(cycle)] ?? textOf(cycle);
|
|
183
|
+
const statusLabel = (status) => {
|
|
184
|
+
const value = textOf(status);
|
|
185
|
+
return (
|
|
186
|
+
{
|
|
187
|
+
success: "成功",
|
|
188
|
+
completed: "已完成",
|
|
189
|
+
failed: "失败",
|
|
190
|
+
processing: "处理中",
|
|
191
|
+
pending: "等待中",
|
|
192
|
+
cancelled: "已取消",
|
|
193
|
+
canceled: "已取消",
|
|
194
|
+
refunded: "已退款",
|
|
195
|
+
}[value] ?? value
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
141
199
|
function variantsOf(id) {
|
|
142
200
|
const levels =
|
|
143
201
|
VARIANTS[id] ??
|
|
@@ -226,7 +284,10 @@ function fallbackCatalog(base) {
|
|
|
226
284
|
|
|
227
285
|
// ---- chat catalog --------------------------------------------------------------
|
|
228
286
|
|
|
229
|
-
async function fetchJson(
|
|
287
|
+
async function fetchJson(
|
|
288
|
+
url,
|
|
289
|
+
{ apiKey, oauth = false, signal, headers: extraHeaders } = {},
|
|
290
|
+
) {
|
|
230
291
|
const controller = new AbortController();
|
|
231
292
|
const onAbort = () => controller.abort();
|
|
232
293
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
@@ -235,7 +296,7 @@ async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
|
|
|
235
296
|
headers.set("x-dai-client", CLIENT);
|
|
236
297
|
if (apiKey) {
|
|
237
298
|
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
238
|
-
headers.set("x-api-key", apiKey);
|
|
299
|
+
if (!oauth) headers.set("x-api-key", apiKey);
|
|
239
300
|
}
|
|
240
301
|
try {
|
|
241
302
|
const res = await fetch(url, { headers, signal: controller.signal });
|
|
@@ -249,6 +310,259 @@ async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
|
|
|
249
310
|
}
|
|
250
311
|
}
|
|
251
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
|
+
|
|
252
566
|
/**
|
|
253
567
|
* Every chat model D.AI publishes, keyed by model id. With a key the catalog is
|
|
254
568
|
* narrowed to the models that key may call; a `/v1/models` failure leaves the
|
|
@@ -259,7 +573,11 @@ async function fetchCatalog(base, { key, signal } = {}) {
|
|
|
259
573
|
const models = chatModelsFromDocs(docs, base);
|
|
260
574
|
if (!key || !Object.keys(models).length) return models;
|
|
261
575
|
try {
|
|
262
|
-
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
|
+
});
|
|
263
581
|
const allowed = new Set(
|
|
264
582
|
(listed?.data ?? []).map((model) => textOf(model?.id)).filter(Boolean),
|
|
265
583
|
);
|
|
@@ -269,7 +587,7 @@ async function fetchCatalog(base, { key, signal } = {}) {
|
|
|
269
587
|
// A refused key must reach Magpie as a sign-in failure. A network or
|
|
270
588
|
// server failure keeps the last good catalog instead.
|
|
271
589
|
if (error?.status === 401 || error?.status === 403) {
|
|
272
|
-
throw Object.assign(new Error("D.AI
|
|
590
|
+
throw Object.assign(new Error("D.AI 登录已失效,请重新登录"), {
|
|
273
591
|
status: error.status,
|
|
274
592
|
signIn: "expired",
|
|
275
593
|
});
|
|
@@ -308,44 +626,44 @@ function balanceCard(account, key) {
|
|
|
308
626
|
const total = optionalNumber(balance.total);
|
|
309
627
|
const pending = optionalNumber(balance.pending_reserved);
|
|
310
628
|
const calls = optionalNumber(account?.tokens?.today?.calls);
|
|
311
|
-
const parts = [
|
|
312
|
-
if (pending && pending > 0) parts.push(
|
|
629
|
+
const parts = [`余额 $${usd(total ?? 0)}`];
|
|
630
|
+
if (pending && pending > 0) parts.push(`待结算 $${usd(pending)}`);
|
|
313
631
|
if (textOf(tier.code)) {
|
|
314
632
|
parts.push(
|
|
315
633
|
[
|
|
316
|
-
|
|
317
|
-
|
|
634
|
+
tierLabel(tier.code),
|
|
635
|
+
cycleLabel(tier.billing_cycle),
|
|
318
636
|
optionalNumber(tier.expires_in_days) === undefined
|
|
319
637
|
? undefined
|
|
320
|
-
:
|
|
638
|
+
: `剩余 ${tier.expires_in_days} 天`,
|
|
321
639
|
]
|
|
322
640
|
.filter(Boolean)
|
|
323
641
|
.join(" · "),
|
|
324
642
|
);
|
|
325
643
|
}
|
|
326
644
|
const included = optionalNumber(tier.included_usd_total);
|
|
327
|
-
if (included && included > 0) parts.push(
|
|
645
|
+
if (included && included > 0) parts.push(`套餐额度 $${usd(included)}`);
|
|
328
646
|
// Only a key with a budget envelope has a reservation; the shared pool has none.
|
|
329
647
|
if (self.envelope_active) {
|
|
330
648
|
const reserved =
|
|
331
649
|
(optionalNumber(self.reserved_package_usd) ?? 0) +
|
|
332
650
|
(optionalNumber(self.reserved_recharge_usd) ?? 0);
|
|
333
|
-
if (reserved > 0) parts.push(
|
|
651
|
+
if (reserved > 0) parts.push(`本密钥 $${usd(reserved)}`);
|
|
334
652
|
}
|
|
335
653
|
const requestLimit = optionalNumber(self.daily_request_limit);
|
|
336
654
|
const usdLimit = optionalNumber(self.daily_usd_limit);
|
|
337
655
|
const limits = [
|
|
338
|
-
requestLimit === undefined ? undefined : `${requestLimit}
|
|
339
|
-
usdLimit === undefined ? undefined : `$${usd(usdLimit)}
|
|
656
|
+
requestLimit === undefined ? undefined : `${requestLimit} 次/天`,
|
|
657
|
+
usdLimit === undefined ? undefined : `$${usd(usdLimit)}/天`,
|
|
340
658
|
].filter(Boolean);
|
|
341
|
-
if (limits.length) parts.push(
|
|
659
|
+
if (limits.length) parts.push(`限额 ${limits.join(",")}`);
|
|
342
660
|
if (calls !== undefined) {
|
|
343
|
-
parts.push(
|
|
661
|
+
parts.push(`今日 ${calls} 次调用`);
|
|
344
662
|
}
|
|
345
663
|
return {
|
|
346
|
-
plan:
|
|
664
|
+
plan: tierLabel(tier.code) || key || "D.AI 账号",
|
|
347
665
|
balance: total === undefined ? "" : `$${usd(total)}`,
|
|
348
|
-
windows: [{ name: "D.AI
|
|
666
|
+
windows: [{ name: "D.AI 余额", display: parts.join(" · ") }],
|
|
349
667
|
};
|
|
350
668
|
}
|
|
351
669
|
|
|
@@ -359,14 +677,14 @@ async function callLogCard(base, key) {
|
|
|
359
677
|
});
|
|
360
678
|
const total = numberOr(log?.total, 0);
|
|
361
679
|
const last = Array.isArray(log?.data) ? log.data[0] : undefined;
|
|
362
|
-
const parts = [`${total}
|
|
680
|
+
const parts = [`${total} 次调用`];
|
|
363
681
|
if (last) {
|
|
364
682
|
const settled = optionalNumber(last.settled_usd);
|
|
365
683
|
parts.push(
|
|
366
684
|
[
|
|
367
685
|
textOf(last.created_at).replace("T", " ").slice(0, 16),
|
|
368
686
|
textOf(last.model_code),
|
|
369
|
-
|
|
687
|
+
statusLabel(last.status),
|
|
370
688
|
settled === undefined ? undefined : `$${usd(settled)}`,
|
|
371
689
|
]
|
|
372
690
|
.filter(Boolean)
|
|
@@ -375,7 +693,7 @@ async function callLogCard(base, key) {
|
|
|
375
693
|
}
|
|
376
694
|
return {
|
|
377
695
|
plan: keyLabel(key),
|
|
378
|
-
windows: [{ name: "D.AI
|
|
696
|
+
windows: [{ name: "D.AI 调用", display: parts.join(" · ") }],
|
|
379
697
|
};
|
|
380
698
|
}
|
|
381
699
|
|
|
@@ -531,6 +849,7 @@ async function mediaCatalog(base, input, signal) {
|
|
|
531
849
|
|
|
532
850
|
const DaiPlugin = async (_input, options = {}) => {
|
|
533
851
|
const base = normalizeBase(options?.baseUrl ?? process.env.DAI_BASE_URL) ?? DEFAULT_BASE;
|
|
852
|
+
const oauthClientId = textOf(options?.oauthClientId) || OAUTH_CLIENT_ID;
|
|
534
853
|
const offline = fallbackCatalog(base);
|
|
535
854
|
|
|
536
855
|
// One catalog read per account, reused until it goes stale. Keyed by the
|
|
@@ -564,43 +883,70 @@ const DaiPlugin = async (_input, options = {}) => {
|
|
|
564
883
|
auth: {
|
|
565
884
|
provider: PROVIDER,
|
|
566
885
|
|
|
567
|
-
|
|
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
|
+
],
|
|
568
900
|
|
|
569
901
|
async loader(getAuth) {
|
|
570
902
|
const auth = await getAuth();
|
|
571
|
-
const
|
|
572
|
-
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
|
+
}
|
|
573
918
|
return {
|
|
574
919
|
baseURL: apiBase(base),
|
|
575
|
-
apiKey:
|
|
920
|
+
apiKey: credential,
|
|
576
921
|
headers: { "x-dai-client": CLIENT },
|
|
577
922
|
};
|
|
578
923
|
},
|
|
579
924
|
|
|
580
|
-
// The account behind
|
|
581
|
-
// from
|
|
582
|
-
//
|
|
583
|
-
// cannot otherwise read. The card says the balance as the wallet's
|
|
584
|
-
// 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.
|
|
585
928
|
async usage(getAuth) {
|
|
586
929
|
const auth = await getAuth();
|
|
587
|
-
const
|
|
588
|
-
if (
|
|
589
|
-
return { windows: [], error: "D.AI
|
|
930
|
+
const credential = credentialOf(auth);
|
|
931
|
+
if (!credential) {
|
|
932
|
+
return { windows: [], error: "D.AI:请先登录" };
|
|
590
933
|
}
|
|
591
934
|
try {
|
|
592
|
-
const account = await fetchJson(`${base}/api/ai/account`, {
|
|
593
|
-
|
|
935
|
+
const account = await fetchJson(`${base}/api/ai/account`, {
|
|
936
|
+
apiKey: credential,
|
|
937
|
+
oauth: isOAuthAuth(auth),
|
|
938
|
+
});
|
|
939
|
+
return balanceCard(account, authLabel(auth));
|
|
594
940
|
} catch (error) {
|
|
595
941
|
const status = error?.status;
|
|
596
942
|
if (status === 401 || status === 403) {
|
|
597
|
-
return { windows: [], error: "D.AI
|
|
943
|
+
return { windows: [], error: "D.AI 登录已失效", signIn: "expired" };
|
|
598
944
|
}
|
|
599
945
|
// A deployment older than the endpoint answers 404. Its call log
|
|
600
946
|
// is still there, and counting calls beats showing an error.
|
|
601
|
-
if (status === 404) {
|
|
947
|
+
if (status === 404 && !isOAuthAuth(auth)) {
|
|
602
948
|
try {
|
|
603
|
-
return await callLogCard(base,
|
|
949
|
+
return await callLogCard(base, credential);
|
|
604
950
|
} catch (fallbackError) {
|
|
605
951
|
return { windows: [], error: String(fallbackError?.message ?? fallbackError) };
|
|
606
952
|
}
|
|
@@ -616,10 +962,10 @@ const DaiPlugin = async (_input, options = {}) => {
|
|
|
616
962
|
// The account's own list when D.AI answers, else the catalog the
|
|
617
963
|
// config hook declared, so a failed fetch never empties the provider.
|
|
618
964
|
async models(provider, { auth } = {}) {
|
|
619
|
-
const
|
|
620
|
-
if (!
|
|
965
|
+
const credential = credentialOf(auth);
|
|
966
|
+
if (!credential) return provider.models;
|
|
621
967
|
try {
|
|
622
|
-
const models = await catalogFor(
|
|
968
|
+
const models = await catalogFor(credential, undefined);
|
|
623
969
|
return models ?? provider.models;
|
|
624
970
|
} catch (error) {
|
|
625
971
|
if (error?.signIn === "expired") throw error;
|
|
@@ -686,8 +1032,10 @@ export default { id: PROVIDER, server: DaiPlugin };
|
|
|
686
1032
|
|
|
687
1033
|
/** For tests and manual probing: the pure pieces, never called as a plugin. */
|
|
688
1034
|
export const _internal = {
|
|
1035
|
+
authLabel,
|
|
689
1036
|
balanceCard,
|
|
690
1037
|
buildModel,
|
|
1038
|
+
browserSignIn,
|
|
691
1039
|
fallbackCatalog,
|
|
692
1040
|
fetchCatalog,
|
|
693
1041
|
fetchGatewayModels,
|
|
@@ -698,4 +1046,7 @@ export const _internal = {
|
|
|
698
1046
|
mediaModel,
|
|
699
1047
|
mediaRows,
|
|
700
1048
|
normalizeBase,
|
|
1049
|
+
pkcePair,
|
|
1050
|
+
refreshOAuth,
|
|
1051
|
+
tokenRequest,
|
|
701
1052
|
};
|
package/package.json
CHANGED