@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.
Files changed (3) hide show
  1. package/README.md +30 -10
  2. package/index.mjs +349 -33
  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.3`. 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.3";
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(url, { apiKey, signal, headers: extraHeaders } = {}) {
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`, { apiKey: key, signal });
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 拒绝了该 API 密钥,请重新登录"), {
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) || keyLabel(key),
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
- methods: [{ type: "api", label: "D.AI API 密钥", 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
+ ],
603
900
 
604
901
  async loader(getAuth) {
605
902
  const auth = await getAuth();
606
- const key = textOf(auth?.key);
607
- 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
+ }
608
918
  return {
609
919
  baseURL: apiBase(base),
610
- apiKey: key,
920
+ apiKey: credential,
611
921
  headers: { "x-dai-client": CLIENT },
612
922
  };
613
923
  },
614
924
 
615
- // The account behind the key: wallet balance, tier and token totals,
616
- // from D.AI's own endpoint (`GET /api/ai/account`). A `ddsk_` key may
617
- // call it, which is the point: the plugin can show what a D.AI key
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 key = textOf(auth?.key);
623
- if (auth?.type !== "api" || !key) {
624
- return { windows: [], error: "D.AI:请使用 API 密钥登录" };
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`, { apiKey: key });
628
- 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));
629
940
  } catch (error) {
630
941
  const status = error?.status;
631
942
  if (status === 401 || status === 403) {
632
- return { windows: [], error: "D.AI 拒绝了该 API 密钥", signIn: "expired" };
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, key);
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 key = textOf(auth?.key);
655
- if (!key) return provider.models;
965
+ const credential = credentialOf(auth);
966
+ if (!credential) return provider.models;
656
967
  try {
657
- const models = await catalogFor(key, undefined);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d-ai/magpie",
3
- "version": "0.1.3",
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",