@d-ai/magpie 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -10
  2. package/index.mjs +359 -34
  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.5`. 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.5";
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) => {
@@ -205,9 +228,18 @@ function buildModel(id, base, rate = {}) {
205
228
  output: numberOr(rate.output_usd_per_1m_tokens, 0),
206
229
  };
207
230
  const cacheRead = cacheRate(id, rate);
208
- if (cacheRead !== undefined) cost.cache_read = cacheRead;
209
231
  const cacheWrite = positiveNumber(rules.cache_write_usd_per_1m_tokens);
232
+ if (cacheRead !== undefined) cost.cache_read = cacheRead;
210
233
  if (cacheWrite !== undefined) cost.cache_write = cacheWrite;
234
+ // OpenCode reads the flat `cache_read` / `cache_write` above; magpie's
235
+ // plugin reader looks under `cache.read` / `cache.write`, so it saw every
236
+ // subscription model as having free cache (a real D.AI model showed
237
+ // 0.002 / 0.02 here and 0 / 0 in the price panel). Send both shapes.
238
+ if (cacheRead !== undefined || cacheWrite !== undefined) {
239
+ cost.cache = {};
240
+ if (cacheRead !== undefined) cost.cache.read = cacheRead;
241
+ if (cacheWrite !== undefined) cost.cache.write = cacheWrite;
242
+ }
211
243
  return {
212
244
  id,
213
245
  name: id,
@@ -261,7 +293,10 @@ function fallbackCatalog(base) {
261
293
 
262
294
  // ---- chat catalog --------------------------------------------------------------
263
295
 
264
- async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
296
+ async function fetchJson(
297
+ url,
298
+ { apiKey, oauth = false, signal, headers: extraHeaders } = {},
299
+ ) {
265
300
  const controller = new AbortController();
266
301
  const onAbort = () => controller.abort();
267
302
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -270,7 +305,7 @@ async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
270
305
  headers.set("x-dai-client", CLIENT);
271
306
  if (apiKey) {
272
307
  headers.set("Authorization", `Bearer ${apiKey}`);
273
- headers.set("x-api-key", apiKey);
308
+ if (!oauth) headers.set("x-api-key", apiKey);
274
309
  }
275
310
  try {
276
311
  const res = await fetch(url, { headers, signal: controller.signal });
@@ -284,6 +319,259 @@ async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
284
319
  }
285
320
  }
286
321
 
322
+ // ---- OAuth ---------------------------------------------------------------------
323
+
324
+ function pkcePair() {
325
+ const verifier = randomBytes(32).toString("base64url");
326
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
327
+ return { verifier, challenge };
328
+ }
329
+
330
+ function oauthForm(values) {
331
+ const body = new URLSearchParams();
332
+ for (const [key, value] of Object.entries(values)) {
333
+ if (value !== undefined && value !== null && String(value) !== "") {
334
+ body.set(key, String(value));
335
+ }
336
+ }
337
+ return body;
338
+ }
339
+
340
+ async function tokenRequest(base, values, signal) {
341
+ const res = await fetch(`${base}/api/oauth/token`, {
342
+ method: "POST",
343
+ headers: {
344
+ "content-type": "application/x-www-form-urlencoded",
345
+ accept: "application/json",
346
+ "x-dai-client": CLIENT,
347
+ },
348
+ body: oauthForm(values),
349
+ signal: signal
350
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
351
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
352
+ });
353
+ const payload = await res.json().catch(() => null);
354
+ if (!res.ok) {
355
+ return {
356
+ status: res.status,
357
+ error: textOf(payload?.error),
358
+ description: textOf(payload?.error_description),
359
+ };
360
+ }
361
+ return payload ?? {};
362
+ }
363
+
364
+ /**
365
+ * A loopback callback for one OAuth sign-in. Binding to port 0 keeps the
366
+ * callback flexible without reserving a fixed port on the user's Mac.
367
+ */
368
+ function callbackServer() {
369
+ return new Promise((resolve, reject) => {
370
+ const server = createServer();
371
+ const sockets = new Set();
372
+ const waiters = new Set();
373
+ let accepted = false;
374
+ let settled = false;
375
+ let closed = false;
376
+ const settle = (query) => {
377
+ if (settled) return false;
378
+ settled = true;
379
+ for (const waiter of waiters) waiter(query);
380
+ waiters.clear();
381
+ return true;
382
+ };
383
+ const close = () => {
384
+ if (closed) return;
385
+ closed = true;
386
+ server.close(() => {
387
+ for (const socket of sockets) socket.destroy();
388
+ });
389
+ };
390
+ server.on("connection", (socket) => {
391
+ sockets.add(socket);
392
+ socket.on("close", () => sockets.delete(socket));
393
+ });
394
+ server.on("error", reject);
395
+ server.on("request", (req, res) => {
396
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
397
+ if (url.pathname !== OAUTH_CALLBACK_PATH) {
398
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
399
+ res.end("not found");
400
+ return;
401
+ }
402
+ if (accepted) {
403
+ res.writeHead(409, { "content-type": "text/plain; charset=utf-8" });
404
+ res.end("callback already received");
405
+ return;
406
+ }
407
+ accepted = true;
408
+ res.writeHead(200, {
409
+ "content-type": "text/html; charset=utf-8",
410
+ "cache-control": "no-store",
411
+ });
412
+ res.end(
413
+ '<!doctype html><meta charset="utf-8"><title>D.AI 回调</title><p>回调已收到,请返回 Magpie 查看结果。</p>',
414
+ () => {
415
+ settle(url.searchParams);
416
+ close();
417
+ },
418
+ );
419
+ });
420
+ server.listen(0, "127.0.0.1", () => {
421
+ const address = server.address();
422
+ if (!address || typeof address === "string") {
423
+ server.close();
424
+ reject(new Error("无法创建 D.AI 本地回调"));
425
+ return;
426
+ }
427
+ const redirectUri = `http://127.0.0.1:${address.port}${OAUTH_CALLBACK_PATH}`;
428
+ resolve({
429
+ redirectUri,
430
+ wait({ signal, timeoutMs = OAUTH_SIGN_IN_TIMEOUT_MS } = {}) {
431
+ return new Promise((done, fail) => {
432
+ if (signal?.aborted) {
433
+ close();
434
+ fail(new Error("D.AI 登录已取消"));
435
+ return;
436
+ }
437
+ let finished = false;
438
+ let timer;
439
+ const cleanup = () => {
440
+ if (timer) clearTimeout(timer);
441
+ waiters.delete(waiter);
442
+ signal?.removeEventListener("abort", onAbort);
443
+ };
444
+ const finish = (ok, value) => {
445
+ if (finished) return;
446
+ finished = true;
447
+ cleanup();
448
+ if (ok) done(value);
449
+ else fail(value);
450
+ };
451
+ const onAbort = () => {
452
+ close();
453
+ finish(false, new Error("D.AI 登录已取消"));
454
+ };
455
+ const waiter = (query) => {
456
+ finish(true, { query });
457
+ };
458
+ waiters.add(waiter);
459
+ timer = setTimeout(() => {
460
+ close();
461
+ finish(false, new Error("D.AI 登录超时,请重新登录"));
462
+ }, timeoutMs);
463
+ signal?.addEventListener("abort", onAbort, { once: true });
464
+ });
465
+ },
466
+ close,
467
+ });
468
+ });
469
+ });
470
+ }
471
+
472
+ /** Browser sign-in: authorization code + PKCE, callback owned by the plugin. */
473
+ async function browserSignIn(base, clientId = OAUTH_CLIENT_ID) {
474
+ const { verifier, challenge } = pkcePair();
475
+ const state = randomBytes(16).toString("base64url");
476
+ const nonce = randomBytes(16).toString("base64url");
477
+ const callback = await callbackServer();
478
+ const authorize = new URL(`${base}/api/oauth/authorize`);
479
+ authorize.searchParams.set("client_id", clientId);
480
+ authorize.searchParams.set("redirect_uri", callback.redirectUri);
481
+ authorize.searchParams.set("response_type", "code");
482
+ authorize.searchParams.set("scope", OAUTH_SCOPES);
483
+ authorize.searchParams.set("state", state);
484
+ authorize.searchParams.set("nonce", nonce);
485
+ authorize.searchParams.set("code_challenge", challenge);
486
+ authorize.searchParams.set("code_challenge_method", "S256");
487
+ return {
488
+ url: authorize.toString(),
489
+ instructions: "在浏览器中登录 D.AI 并同意授权;完成后会自动回到 Magpie。",
490
+ method: "auto",
491
+ async callback({ signal, timeoutMs = OAUTH_SIGN_IN_TIMEOUT_MS } = {}) {
492
+ try {
493
+ const { query } = await callback.wait({ signal, timeoutMs });
494
+ if (textOf(query.get("state")) !== state) {
495
+ return { type: "failed", error: "D.AI 登录状态校验失败" };
496
+ }
497
+ const error = textOf(query.get("error"));
498
+ if (error) {
499
+ return {
500
+ type: "failed",
501
+ error: textOf(query.get("error_description")) || `D.AI 登录失败:${error}`,
502
+ };
503
+ }
504
+ const code = textOf(query.get("code"));
505
+ if (!code) return { type: "failed", error: "D.AI 没有返回授权码" };
506
+ const token = await tokenRequest(
507
+ base,
508
+ {
509
+ grant_type: "authorization_code",
510
+ client_id: clientId,
511
+ code,
512
+ redirect_uri: callback.redirectUri,
513
+ code_verifier: verifier,
514
+ },
515
+ signal,
516
+ );
517
+ if (!token.access_token) {
518
+ return {
519
+ type: "failed",
520
+ error:
521
+ token.description ||
522
+ token.error ||
523
+ `D.AI 换取令牌失败(HTTP ${token.status ?? "?"})`,
524
+ };
525
+ }
526
+ const who = await fetchJson(`${base}/api/oauth/userinfo`, {
527
+ apiKey: token.access_token,
528
+ oauth: true,
529
+ }).catch(() => null);
530
+ const accountId =
531
+ textOf(who?.email) || textOf(who?.preferred_username) || textOf(who?.sub);
532
+ return {
533
+ type: "success",
534
+ access: token.access_token,
535
+ refresh: token.refresh_token ?? "",
536
+ expires: Date.now() + numberOr(token.expires_in, 3600) * 1000,
537
+ ...(accountId ? { accountId } : {}),
538
+ ...(textOf(who?.sub) ? { uid: textOf(who.sub) } : {}),
539
+ };
540
+ } catch (error) {
541
+ return { type: "failed", error: String(error?.message ?? error) };
542
+ } finally {
543
+ callback.close();
544
+ }
545
+ },
546
+ };
547
+ }
548
+
549
+ async function refreshOAuth(base, auth, clientId = OAUTH_CLIENT_ID) {
550
+ if (!isOAuthAuth(auth)) return;
551
+ const refresh = textOf(auth?.refresh);
552
+ if (!refresh) throw Object.assign(new Error("D.AI 登录已失效"), { signIn: "expired" });
553
+ const token = await tokenRequest(base, {
554
+ grant_type: "refresh_token",
555
+ client_id: clientId,
556
+ refresh_token: refresh,
557
+ });
558
+ if (!token.access_token) {
559
+ const expired =
560
+ token.status === 400 ||
561
+ token.error === "invalid_grant" ||
562
+ token.error === "invalid_refresh_token";
563
+ throw Object.assign(
564
+ new Error(token.description || token.error || "D.AI 刷新登录失败"),
565
+ expired ? { signIn: "expired" } : {},
566
+ );
567
+ }
568
+ return {
569
+ access: token.access_token,
570
+ refresh: token.refresh_token ?? refresh,
571
+ expires: Date.now() + numberOr(token.expires_in, 3600) * 1000,
572
+ };
573
+ }
574
+
287
575
  /**
288
576
  * Every chat model D.AI publishes, keyed by model id. With a key the catalog is
289
577
  * narrowed to the models that key may call; a `/v1/models` failure leaves the
@@ -294,7 +582,11 @@ async function fetchCatalog(base, { key, signal } = {}) {
294
582
  const models = chatModelsFromDocs(docs, base);
295
583
  if (!key || !Object.keys(models).length) return models;
296
584
  try {
297
- const listed = await fetchJson(`${apiBase(base)}/models`, { apiKey: key, signal });
585
+ const listed = await fetchJson(`${apiBase(base)}/models`, {
586
+ apiKey: key,
587
+ oauth: key.startsWith("ddoa_"),
588
+ signal,
589
+ });
298
590
  const allowed = new Set(
299
591
  (listed?.data ?? []).map((model) => textOf(model?.id)).filter(Boolean),
300
592
  );
@@ -304,7 +596,7 @@ async function fetchCatalog(base, { key, signal } = {}) {
304
596
  // A refused key must reach Magpie as a sign-in failure. A network or
305
597
  // server failure keeps the last good catalog instead.
306
598
  if (error?.status === 401 || error?.status === 403) {
307
- throw Object.assign(new Error("D.AI 拒绝了该 API 密钥,请重新登录"), {
599
+ throw Object.assign(new Error("D.AI 登录已失效,请重新登录"), {
308
600
  status: error.status,
309
601
  signIn: "expired",
310
602
  });
@@ -378,7 +670,7 @@ function balanceCard(account, key) {
378
670
  parts.push(`今日 ${calls} 次调用`);
379
671
  }
380
672
  return {
381
- plan: tierLabel(tier.code) || keyLabel(key),
673
+ plan: tierLabel(tier.code) || key || "D.AI 账号",
382
674
  balance: total === undefined ? "" : `$${usd(total)}`,
383
675
  windows: [{ name: "D.AI 余额", display: parts.join(" · ") }],
384
676
  };
@@ -566,6 +858,7 @@ async function mediaCatalog(base, input, signal) {
566
858
 
567
859
  const DaiPlugin = async (_input, options = {}) => {
568
860
  const base = normalizeBase(options?.baseUrl ?? process.env.DAI_BASE_URL) ?? DEFAULT_BASE;
861
+ const oauthClientId = textOf(options?.oauthClientId) || OAUTH_CLIENT_ID;
569
862
  const offline = fallbackCatalog(base);
570
863
 
571
864
  // One catalog read per account, reused until it goes stale. Keyed by the
@@ -599,43 +892,70 @@ const DaiPlugin = async (_input, options = {}) => {
599
892
  auth: {
600
893
  provider: PROVIDER,
601
894
 
602
- methods: [{ type: "api", label: "D.AI API 密钥", placeholder: "ddsk_…" }],
895
+ refreshLead: 5 * 60 * 1000,
896
+
897
+ async refresh(auth) {
898
+ return refreshOAuth(base, auth, oauthClientId);
899
+ },
900
+
901
+ methods: [
902
+ {
903
+ type: "oauth",
904
+ label: "使用 D.AI 账号登录",
905
+ authorize: () => browserSignIn(base, oauthClientId),
906
+ },
907
+ { type: "api", label: "D.AI API 密钥", placeholder: "ddsk_…" },
908
+ ],
603
909
 
604
910
  async loader(getAuth) {
605
911
  const auth = await getAuth();
606
- const key = textOf(auth?.key);
607
- if (auth?.type !== "api" || !key) return {};
912
+ const credential = credentialOf(auth);
913
+ if (!credential) return {};
914
+ if (isOAuthAuth(auth)) {
915
+ return {
916
+ baseURL: apiBase(base),
917
+ apiKey: credential,
918
+ headers: { "x-dai-client": CLIENT },
919
+ fetch(input, init) {
920
+ const headers = new Headers(init?.headers);
921
+ headers.delete("x-api-key");
922
+ headers.set("authorization", `Bearer ${credential}`);
923
+ return fetch(input, { ...init, headers });
924
+ },
925
+ };
926
+ }
608
927
  return {
609
928
  baseURL: apiBase(base),
610
- apiKey: key,
929
+ apiKey: credential,
611
930
  headers: { "x-dai-client": CLIENT },
612
931
  };
613
932
  },
614
933
 
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.
934
+ // The account behind either sign-in: wallet balance, tier and token
935
+ // totals from `GET /api/ai/account`. The card says the balance as the
936
+ // wallet's total, then what this key may spend and what it has used.
620
937
  async usage(getAuth) {
621
938
  const auth = await getAuth();
622
- const key = textOf(auth?.key);
623
- if (auth?.type !== "api" || !key) {
624
- return { windows: [], error: "D.AI:请使用 API 密钥登录" };
939
+ const credential = credentialOf(auth);
940
+ if (!credential) {
941
+ return { windows: [], error: "D.AI:请先登录" };
625
942
  }
626
943
  try {
627
- const account = await fetchJson(`${base}/api/ai/account`, { apiKey: key });
628
- return balanceCard(account, key);
944
+ const account = await fetchJson(`${base}/api/ai/account`, {
945
+ apiKey: credential,
946
+ oauth: isOAuthAuth(auth),
947
+ });
948
+ return balanceCard(account, authLabel(auth));
629
949
  } catch (error) {
630
950
  const status = error?.status;
631
951
  if (status === 401 || status === 403) {
632
- return { windows: [], error: "D.AI 拒绝了该 API 密钥", signIn: "expired" };
952
+ return { windows: [], error: "D.AI 登录已失效", signIn: "expired" };
633
953
  }
634
954
  // A deployment older than the endpoint answers 404. Its call log
635
955
  // is still there, and counting calls beats showing an error.
636
- if (status === 404) {
956
+ if (status === 404 && !isOAuthAuth(auth)) {
637
957
  try {
638
- return await callLogCard(base, key);
958
+ return await callLogCard(base, credential);
639
959
  } catch (fallbackError) {
640
960
  return { windows: [], error: String(fallbackError?.message ?? fallbackError) };
641
961
  }
@@ -651,10 +971,10 @@ const DaiPlugin = async (_input, options = {}) => {
651
971
  // The account's own list when D.AI answers, else the catalog the
652
972
  // config hook declared, so a failed fetch never empties the provider.
653
973
  async models(provider, { auth } = {}) {
654
- const key = textOf(auth?.key);
655
- if (!key) return provider.models;
974
+ const credential = credentialOf(auth);
975
+ if (!credential) return provider.models;
656
976
  try {
657
- const models = await catalogFor(key, undefined);
977
+ const models = await catalogFor(credential, undefined);
658
978
  return models ?? provider.models;
659
979
  } catch (error) {
660
980
  if (error?.signIn === "expired") throw error;
@@ -721,8 +1041,10 @@ export default { id: PROVIDER, server: DaiPlugin };
721
1041
 
722
1042
  /** For tests and manual probing: the pure pieces, never called as a plugin. */
723
1043
  export const _internal = {
1044
+ authLabel,
724
1045
  balanceCard,
725
1046
  buildModel,
1047
+ browserSignIn,
726
1048
  fallbackCatalog,
727
1049
  fetchCatalog,
728
1050
  fetchGatewayModels,
@@ -733,4 +1055,7 @@ export const _internal = {
733
1055
  mediaModel,
734
1056
  mediaRows,
735
1057
  normalizeBase,
1058
+ pkcePair,
1059
+ refreshOAuth,
1060
+ tokenRequest,
736
1061
  };
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.5",
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",