@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.
Files changed (3) hide show
  1. package/README.md +30 -10
  2. package/index.mjs +399 -48
  3. 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 # asks for the API key
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 key comes from the D.AI console (`/#/apikeys`). Custom deployments can point
24
- at another host with `magpie plugin options dai '{"baseUrl":"https://dai.example"}'`
25
- or `DAI_BASE_URL`.
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 the key: the wallet balance and pending
82
- reservation, the tier and its cycle, included USD, the key's own reservation and
83
- daily limits, and how many calls it made today. That comes from
84
- `GET /api/ai/account`, an endpoint authenticated by the `ddsk_` key itself.
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
- - Requests go out with `Authorization: Bearer <key>` plus `x-api-key`, and carry
103
- `x-dai-client: magpie/0.1.2`. Gateway requests also carry the stable Magpie
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
- // One D.AI API key (`ddsk_…`) signs an account in; magpie may hold several
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_`-authenticated endpoint
12
- // added for exactly this: the wallet balance, the tier, and what the key has
13
- // used. The older JWT-only endpoints (`/api/usd/me`, `/api/user/usage-stats`)
14
- // cannot serve a plugin, and the key's call log only counts calls, not money.
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.2";
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 ? `key ${id}` : "API key";
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(url, { apiKey, signal, headers: extraHeaders } = {}) {
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`, { apiKey: key, signal });
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 refused the API key; sign in again"), {
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 = [`$${usd(total ?? 0)} left`];
312
- if (pending && pending > 0) parts.push(`$${usd(pending)} pending`);
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
- textOf(tier.code),
317
- textOf(tier.billing_cycle),
634
+ tierLabel(tier.code),
635
+ cycleLabel(tier.billing_cycle),
318
636
  optionalNumber(tier.expires_in_days) === undefined
319
637
  ? undefined
320
- : `${tier.expires_in_days}d left`,
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(`$${usd(included)} included`);
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(`this key $${usd(reserved)}`);
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} requests/day`,
339
- usdLimit === undefined ? undefined : `$${usd(usdLimit)}/day`,
656
+ requestLimit === undefined ? undefined : `${requestLimit} 次/天`,
657
+ usdLimit === undefined ? undefined : `$${usd(usdLimit)}/天`,
340
658
  ].filter(Boolean);
341
- if (limits.length) parts.push(`limits ${limits.join(", ")}`);
659
+ if (limits.length) parts.push(`限额 ${limits.join(",")}`);
342
660
  if (calls !== undefined) {
343
- parts.push(`${calls} call${calls === 1 ? "" : "s"} today`);
661
+ parts.push(`今日 ${calls} 次调用`);
344
662
  }
345
663
  return {
346
- plan: textOf(tier.code) || keyLabel(key),
664
+ plan: tierLabel(tier.code) || key || "D.AI 账号",
347
665
  balance: total === undefined ? "" : `$${usd(total)}`,
348
- windows: [{ name: "D.AI balance", display: parts.join(" · ") }],
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} call${total === 1 ? "" : "s"}`];
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
- textOf(last.status),
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 calls", display: parts.join(" · ") }],
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
- methods: [{ type: "api", label: "D.AI API key", placeholder: "ddsk_…" }],
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 key = textOf(auth?.key);
572
- if (auth?.type !== "api" || !key) return {};
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: key,
920
+ apiKey: credential,
576
921
  headers: { "x-dai-client": CLIENT },
577
922
  };
578
923
  },
579
924
 
580
- // The account behind the key: wallet balance, tier and token totals,
581
- // from D.AI's own endpoint (`GET /api/ai/account`). A `ddsk_` key may
582
- // call it, which is the point: the plugin can show what a D.AI key
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 key = textOf(auth?.key);
588
- if (auth?.type !== "api" || !key) {
589
- return { windows: [], error: "D.AI: sign in with an API key" };
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`, { apiKey: key });
593
- return balanceCard(account, key);
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 refused the API key", signIn: "expired" };
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, key);
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 key = textOf(auth?.key);
620
- if (!key) return provider.models;
965
+ const credential = credentialOf(auth);
966
+ if (!credential) return provider.models;
621
967
  try {
622
- const models = await catalogFor(key, undefined);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d-ai/magpie",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "D.AI (dai.itbug.shop) chat models in magpie and OpenCode, plus a read-only image/video/audio model catalog",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",