@d-ai/magpie 0.1.1 → 0.1.3

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 (4) hide show
  1. package/README.md +56 -21
  2. package/index.mjs +352 -44
  3. package/package.json +3 -2
  4. package/LICENSE +0 -21
package/README.md CHANGED
@@ -1,16 +1,18 @@
1
1
  # D.AI for magpie / OpenCode
2
2
 
3
- Use your [D.AI](https://dai.itbug.shop) models — GPT-6, Claude Opus 5.5, Grok 4.7,
4
- DeepSeek V4.1, GLM 5.3, Gemini 3.8 — from any agent that speaks through
5
- [magpie](https://usemagpie.ai) or OpenCode.
3
+ Use your [D.AI](https://dai.itbug.shop) models — GPT-6, Claude, Grok, DeepSeek,
4
+ GLM and Gemini — from any agent that speaks through
5
+ [magpie](https://usemagpie.ai) or OpenCode. The same plugin exposes a read-only
6
+ catalog of D.AI image, video and audio models to Magpie's plugin-tools MCP.
6
7
 
7
8
  One `ddsk_…` API key is one account, and magpie keeps several.
8
9
 
9
10
  ## Add it
10
11
 
11
12
  ```sh
12
- magpie plugin add mdddj/d-ai-magpie # from GitHub (the unofficial list installs this)
13
+ magpie plugin add mdddj/d-ai-magpie # from GitHub
13
14
  magpie plugin add @d-ai/magpie # from npm, once published
15
+ magpie plugin add ./sdks/d-ai-magpie # from a folder, for development
14
16
  magpie plugin login dai # asks for the API key
15
17
  magpie provider test dai # one tiny call per API
16
18
  ```
@@ -22,17 +24,13 @@ The key comes from the D.AI console (`/#/apikeys`). Custom deployments can point
22
24
  at another host with `magpie plugin options dai '{"baseUrl":"https://dai.example"}'`
23
25
  or `DAI_BASE_URL`.
24
26
 
25
- `DAI_API_KEY=ddsk_… ./e2e.sh` runs the same checks in a throwaway `HOME`, so it
26
- never touches the magpie you actually use. Add `--live` to make one real call
27
- on each API.
27
+ ## Chat models
28
28
 
29
- ## What it exposes
30
-
31
- 15 token-metered chat models, in two APIs:
29
+ The live catalog currently publishes 17 token-metered chat models, in two APIs:
32
30
 
33
31
  | API | Models | Path |
34
32
  | --- | --- | --- |
35
- | OpenAI chat completions | `deepseek-v4.1-flash`, `gemini-3.8-flash`, `glm-5.3-flash`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `gpt-6-sol`, `grok-4.6`, `grok-4.7` | `/v1/chat/completions` |
33
+ | OpenAI chat completions | `deepseek-v4.1-flash`, `deepseek-v4.1-flash-1017`, `gemini-3.8-flash`, `glm-5.3-flash`, `gpt-5.6-luna/sol/terra`, `gpt-6-astra/sol`, `gpt-6.1-sol`, `grok-4.6/4.7` | `/v1/chat/completions` |
36
34
  | Anthropic Messages | `claude-fable-5`, `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5` | `/v1/messages` |
37
35
 
38
36
  The model list and the prices come from D.AI's public catalog on every start
@@ -42,15 +40,48 @@ new model appears without a plugin update. A built-in list is used when D.AI
42
40
  cannot be reached while the provider is first configured.
43
41
 
44
42
  Every model reports its context window, its output cap, its USD/1M-token prices
45
- and its thinking levels. Image input is marked for the families D.AI serves with
46
- vision (GPT-5/6, Claude, Gemini, Grok, DeepSeek, GLM).
43
+ and its thinking levels. Image input is marked for GPT-5/6, Claude, DeepSeek and
44
+ GLM, matching the modality table used by `sdks/pi`.
45
+
46
+ ## Media catalog
47
+
48
+ The plugin registers one free, read-only Magpie tool, `media_catalog`. Add
49
+ Magpie's plugin-tools MCP server to an agent:
50
+
51
+ ```sh
52
+ claude mcp add --transport http magpie-tools http://127.0.0.1:3425/mcp/magpie/tools
53
+ ```
54
+
55
+ Then ask for models by kind, capability, model code or search text:
56
+
57
+ ```json
58
+ { "kind": "image", "query": "gpt-image", "limit": 10 }
59
+ { "kind": "video", "capabilityCode": "gemini_video" }
60
+ { "kind": "image", "capabilityCode": "midjourney", "serviceCode": "midjourney_imagine" }
61
+ { "kind": "audio", "capabilityCode": "suno_speech", "model": "suno-speech" }
62
+ ```
63
+
64
+ It reads `GET /api/system/public/gateway-models`, so it does not need an API key
65
+ and does not spend wallet balance. Each result carries the executable
66
+ `submitPath`, the optional `fetchPath`, `resultKind`, the complete parameter
67
+ schema and the public `tierRates`. Some models (Midjourney, HappyHorse, Gemini
68
+ Video and FlowMusic) have several operation variants: filter with
69
+ `serviceCode` to choose one.
70
+
71
+ The short plugin name depends on how it was installed: a folder named
72
+ `d-ai-magpie` exposes `d-ai-magpie_media_catalog`; the published
73
+ `@d-ai/magpie` package exposes `magpie_media_catalog`.
74
+
75
+ Paid generation and account-scoped job tools are intentionally not included.
76
+ Magpie's tool process does not receive the signed-in `dai` provider's API key,
77
+ so adding generation here would require a separate tool-auth design.
47
78
 
48
79
  ## Usage
49
80
 
50
- `magpie quota` shows the account behind the key: the wallet balance, the tier
51
- and its cycle, how much of the balance this key alone may spend, and how many
52
- calls it made today. That comes from `GET /api/ai/account`, an endpoint
53
- authenticated by the `ddsk_` key itself.
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.
54
85
 
55
86
  A D.AI key has no plan window — it spends the account's shared wallet — so the
56
87
  card reads as a balance, not a quota window. Two keys of one account show the
@@ -58,12 +89,16 @@ same balance but their own reservations and call counts.
58
89
 
59
90
  ## Notes
60
91
 
61
- - Only chat models. D.AI's image, video, music and ASR models are separate
62
- capabilities with their own endpoints; agents here want a chat completion.
92
+ - Chat models use the provider API. Image, video and audio models are separate
93
+ gateway capabilities; `media_catalog` returns their actual submit/fetch
94
+ paths without pretending they are chat completions. ASR is not in the public
95
+ media gateway today; use the authenticated `/api/audio/asr/v1/transcribe`
96
+ endpoint when a deployment enables Fun-ASR.
63
97
  - The usage card needs `GET /api/ai/account` on the D.AI deployment. Against an
64
98
  older one (404) the plugin falls back to the key's call log and shows call
65
99
  counts instead of a balance.
66
100
  - The plugin never rewrites request bodies. Thinking levels are offered as
67
101
  model variants, and the gateway passes `reasoning_effort` through.
68
- - Requests go out with `Authorization: Bearer <key>` plus `x-api-key`, so
69
- either D.AI route works.
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
104
+ session as `x-dai-session`.
package/index.mjs CHANGED
@@ -13,9 +13,9 @@
13
13
  // used. The older JWT-only endpoints (`/api/usd/me`, `/api/user/usage-stats`)
14
14
  // cannot serve a plugin, and the key's call log only counts calls, not money.
15
15
  //
16
- // Facts the tables below encode (checked 2026-10-15):
16
+ // Facts the tables below encode (checked 2026-10-22):
17
17
  // - The capability codes carrying token-metered chat are
18
- // `openai_chat_completions` (10 models) and `claude_messages` (5 models).
18
+ // `openai_chat_completions` (12 models) and `claude_messages` (5 models).
19
19
  // - `/v1/models` takes the API key as `Authorization: Bearer` or `x-api-key`
20
20
  // and lists only the models that key may use.
21
21
  // - `prepay_completion_token_limit` is D.AI's completion cap and the value a
@@ -26,14 +26,38 @@
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
- // Sections: constants · helpers · catalog · plugin.
29
+ // The same module also gives a read-only media catalog tool. Magpie tools run
30
+ // from a separate MCP server and do not receive the provider account's key, so
31
+ // paid generation and account-scoped job tools are intentionally absent.
32
+ //
33
+ // Sections: constants · helpers · chat catalog · media catalog · plugin.
30
34
 
31
35
  const PROVIDER = "dai";
36
+ const CLIENT = "magpie/0.1.3";
32
37
  const DEFAULT_BASE = "https://dai.itbug.shop";
33
38
  const OPENAI_NPM = "@ai-sdk/openai-compatible";
34
39
  const ANTHROPIC_NPM = "@ai-sdk/anthropic";
35
40
  const CATALOG_TTL_MS = 5 * 60 * 1000;
36
41
  const REQUEST_TIMEOUT_MS = 20_000;
42
+ const MEDIA_CATALOG_LIMIT = 20;
43
+ const MEDIA_KINDS = new Set(["image", "video", "audio"]);
44
+
45
+ /** Display names for the public tiers; unknown codes stay unchanged. */
46
+ const TIER_NAMES = {
47
+ starter: "入门版",
48
+ basic: "基础版",
49
+ pro: "专业版",
50
+ max: "旗舰版",
51
+ trial: "体验版",
52
+ };
53
+
54
+ /** Billing cycles as the usage card says them. */
55
+ const BILLING_CYCLES = {
56
+ monthly: "月付",
57
+ quarterly: "季付",
58
+ semiannual: "半年付",
59
+ annual: "年付",
60
+ };
37
61
 
38
62
  /** Capability codes whose models are token-metered chat models. */
39
63
  const CHAT_CAPABILITIES = new Set(["openai_chat_completions", "claude_messages"]);
@@ -47,8 +71,10 @@ const VARIANTS = {
47
71
  // GPT-6 cannot turn thinking off.
48
72
  "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"],
49
73
  "gpt-6-sol": ["low", "medium", "high", "xhigh", "max"],
74
+ "gpt-6.1-sol": ["low", "medium", "high", "xhigh", "max"],
50
75
  // River rejects minimal, medium and xhigh on DeepSeek-V4.1-Flash; `none` is off.
51
76
  "deepseek-v4.1-flash": ["none", "low", "high", "max"],
77
+ "deepseek-v4.1-flash-1017": ["none", "low", "high", "max"],
52
78
  // GLM-5.3-Flash cannot disable reasoning; River also rejects minimal/medium/xhigh.
53
79
  "glm-5.3-flash": ["low", "high", "max"],
54
80
  // Claude: the adaptive-effort models take xhigh/max, Opus 5.5 the full range.
@@ -60,10 +86,9 @@ const VARIANTS = {
60
86
  };
61
87
 
62
88
  /**
63
- * Models that take images as well as text, same set pi's tables mark.
64
- * Gemini, Grok and GPT-5.6 each carry `cache_write_usd_per_1m_tokens: null`
65
- * on D.AI today, so a null there reads as "no cache write price", not as a
66
- * missing check: only listed, non-zero rates become a model's cache price.
89
+ * Models that take images as well as text. D.AI serves GPT-5/6, Claude,
90
+ * DeepSeek and GLM with image input. Gemini and Grok are text-only through
91
+ * this gateway until pi's modality table marks them otherwise.
67
92
  */
68
93
  const VISION = /^(?:gpt-5|gpt-6|deepseek-|glm-|claude-)/u;
69
94
 
@@ -74,6 +99,7 @@ const VISION = /^(?:gpt-5|gpt-6|deepseek-|glm-|claude-)/u;
74
99
  */
75
100
  const FALLBACK_ROWS = [
76
101
  ["deepseek-v4.1-flash", 1_048_576, 128_000, 0.15, 0.6, 0.003],
102
+ ["deepseek-v4.1-flash-1017", 1_048_576, 128_000, 0.15, 0.6, 0.003],
77
103
  ["gemini-3.8-flash", 1_050_000, 128_000, 0.5, 3, 0.05],
78
104
  ["glm-5.3-flash", 1_048_576, 128_000, 0.15, 0.5, 0.03],
79
105
  ["gpt-5.6-luna", 1_050_000, 128_000, 0.2, 1.2, 0.02],
@@ -81,6 +107,7 @@ const FALLBACK_ROWS = [
81
107
  ["gpt-5.6-terra", 1_050_000, 128_000, 2, 12, 0.2],
82
108
  ["gpt-6-astra", 1_050_000, 128_000, 10, 50, 1],
83
109
  ["gpt-6-sol", 1_050_000, 128_000, 2, 10, 0.2],
110
+ ["gpt-6.1-sol", 1_050_000, 128_000, 2, 10, 0.1],
84
111
  ["grok-4.6", 500_000, 128_000, 1.5, 5, 0.4],
85
112
  ["grok-4.7", 500_000, 128_000, 1.5, 5, 0.4],
86
113
  ["claude-fable-5", 1_000_000, 128_000, 20, 100, 2],
@@ -125,17 +152,39 @@ const isClaudeModel = (id) => id.startsWith("claude-");
125
152
  /** `ddsk_01ABC_secret` → what a card may show, never the secret itself. */
126
153
  function keyLabel(key) {
127
154
  const id = textOf(key).replace(/^ddsk_/u, "").split("_")[0];
128
- return id ? `key ${id}` : "API key";
155
+ return id ? `密钥 ${id}` : "API 密钥";
129
156
  }
130
157
 
158
+ const tierLabel = (code) => TIER_NAMES[textOf(code)] ?? textOf(code);
159
+ const cycleLabel = (cycle) => BILLING_CYCLES[textOf(cycle)] ?? textOf(cycle);
160
+ const statusLabel = (status) => {
161
+ const value = textOf(status);
162
+ return (
163
+ {
164
+ success: "成功",
165
+ completed: "已完成",
166
+ failed: "失败",
167
+ processing: "处理中",
168
+ pending: "等待中",
169
+ cancelled: "已取消",
170
+ canceled: "已取消",
171
+ refunded: "已退款",
172
+ }[value] ?? value
173
+ );
174
+ };
175
+
131
176
  function variantsOf(id) {
132
177
  const levels =
133
178
  VARIANTS[id] ??
134
179
  (/^gpt-5\.6/u.test(id)
135
180
  ? VARIANTS["gpt-5.6-luna"]
136
- : /^gpt-6/u.test(id)
181
+ : /^gpt-6\.1/u.test(id)
182
+ ? VARIANTS["gpt-6.1-sol"]
183
+ : /^gpt-6/u.test(id)
137
184
  ? VARIANTS["gpt-6-astra"]
138
- : undefined);
185
+ : /^deepseek-v4\.1-flash/u.test(id)
186
+ ? VARIANTS["deepseek-v4.1-flash"]
187
+ : undefined);
139
188
  if (!levels) return undefined;
140
189
  return Object.fromEntries(levels.map((level) => [level, {}]));
141
190
  }
@@ -155,13 +204,18 @@ function buildModel(id, base, rate = {}) {
155
204
  input: numberOr(rate.input_usd_per_1m_tokens, 0),
156
205
  output: numberOr(rate.output_usd_per_1m_tokens, 0),
157
206
  };
158
- const cacheRead = positiveNumber(rules.cache_read_usd_per_1m_tokens);
207
+ const cacheRead = cacheRate(id, rate);
159
208
  if (cacheRead !== undefined) cost.cache_read = cacheRead;
160
209
  const cacheWrite = positiveNumber(rules.cache_write_usd_per_1m_tokens);
161
210
  if (cacheWrite !== undefined) cost.cache_write = cacheWrite;
162
211
  return {
163
212
  id,
164
213
  name: id,
214
+ // Magpie's model parser reads these top-level fields. Keep
215
+ // capabilities as well for OpenCode readers that use that shape.
216
+ reasoning: Boolean(variants),
217
+ attachment: vision,
218
+ tool_call: true,
165
219
  api: { id, npm, url: apiBase(base) },
166
220
  provider: { npm, api: apiBase(base) },
167
221
  limit: {
@@ -169,7 +223,7 @@ function buildModel(id, base, rate = {}) {
169
223
  output: Math.max(1, numberOr(rate.prepay_completion_token_limit, 128_000)),
170
224
  },
171
225
  capabilities: {
172
- reasoning: Boolean(variants) || vision,
226
+ reasoning: Boolean(variants),
173
227
  toolcall: true,
174
228
  attachment: vision,
175
229
  input: vision ? { text: true, image: true } : { text: true },
@@ -180,6 +234,17 @@ function buildModel(id, base, rate = {}) {
180
234
  };
181
235
  }
182
236
 
237
+ /**
238
+ * Claude settlement ignores D.AI's published cache rates and weights cached
239
+ * tokens against the input rate, matching sdks/pi/src/discover.ts.
240
+ */
241
+ function cacheRate(id, rate) {
242
+ const published = positiveNumber(rate.billing_rules?.cache_read_usd_per_1m_tokens);
243
+ if (!isClaudeModel(id)) return published;
244
+ const input = numberOr(rate.input_usd_per_1m_tokens, 0);
245
+ return (input * 2) / 20;
246
+ }
247
+
183
248
  function fallbackCatalog(base) {
184
249
  const models = {};
185
250
  for (const [id, context, output, input, out, cacheRead] of FALLBACK_ROWS) {
@@ -194,14 +259,15 @@ function fallbackCatalog(base) {
194
259
  return models;
195
260
  }
196
261
 
197
- // ---- catalog -------------------------------------------------------------------
262
+ // ---- chat catalog --------------------------------------------------------------
198
263
 
199
- async function fetchJson(url, { apiKey, signal } = {}) {
264
+ async function fetchJson(url, { apiKey, signal, headers: extraHeaders } = {}) {
200
265
  const controller = new AbortController();
201
266
  const onAbort = () => controller.abort();
202
267
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
203
268
  signal?.addEventListener("abort", onAbort, { once: true });
204
- const headers = new Headers();
269
+ const headers = new Headers(extraHeaders);
270
+ headers.set("x-dai-client", CLIENT);
205
271
  if (apiKey) {
206
272
  headers.set("Authorization", `Bearer ${apiKey}`);
207
273
  headers.set("x-api-key", apiKey);
@@ -224,16 +290,8 @@ async function fetchJson(url, { apiKey, signal } = {}) {
224
290
  * full catalog in place rather than hiding every model.
225
291
  */
226
292
  async function fetchCatalog(base, { key, signal } = {}) {
227
- const docs = await fetchJson(`${base}/api/system/public/capability-docs`, { signal });
228
- const models = {};
229
- for (const capability of docs?.data ?? []) {
230
- if (!CHAT_CAPABILITIES.has(capability?.capability_code)) continue;
231
- for (const model of capability?.models ?? []) {
232
- const id = textOf(model?.model_code);
233
- if (!id || models[id]) continue;
234
- models[id] = buildModel(id, base, model?.token_rate ?? {});
235
- }
236
- }
293
+ const docs = await fetchCapabilityDocs(base, signal);
294
+ const models = chatModelsFromDocs(docs, base);
237
295
  if (!key || !Object.keys(models).length) return models;
238
296
  try {
239
297
  const listed = await fetchJson(`${apiBase(base)}/models`, { apiKey: key, signal });
@@ -242,11 +300,36 @@ async function fetchCatalog(base, { key, signal } = {}) {
242
300
  );
243
301
  if (!allowed.size) return models;
244
302
  return Object.fromEntries(Object.entries(models).filter(([id]) => allowed.has(id)));
245
- } catch {
303
+ } catch (error) {
304
+ // A refused key must reach Magpie as a sign-in failure. A network or
305
+ // server failure keeps the last good catalog instead.
306
+ if (error?.status === 401 || error?.status === 403) {
307
+ throw Object.assign(new Error("D.AI 拒绝了该 API 密钥,请重新登录"), {
308
+ status: error.status,
309
+ signIn: "expired",
310
+ });
311
+ }
246
312
  return models;
247
313
  }
248
314
  }
249
315
 
316
+ async function fetchCapabilityDocs(base, signal) {
317
+ return fetchJson(`${base}/api/system/public/capability-docs`, { signal });
318
+ }
319
+
320
+ function chatModelsFromDocs(docs, base) {
321
+ const models = {};
322
+ for (const capability of docs?.data ?? []) {
323
+ if (!CHAT_CAPABILITIES.has(capability?.capability_code)) continue;
324
+ for (const model of capability?.models ?? []) {
325
+ const id = textOf(model?.model_code);
326
+ if (!id || models[id]) continue;
327
+ models[id] = buildModel(id, base, model?.token_rate ?? {});
328
+ }
329
+ }
330
+ return models;
331
+ }
332
+
250
333
  // ---- the usage card ------------------------------------------------------------
251
334
 
252
335
  /**
@@ -257,34 +340,47 @@ function balanceCard(account, key) {
257
340
  const balance = account?.balance ?? {};
258
341
  const self = account?.api_key ?? {};
259
342
  const tier = account?.tier ?? {};
260
- const parts = [`$${usd(optionalNumber(balance.total) ?? 0)} left`];
343
+ const total = optionalNumber(balance.total);
344
+ const pending = optionalNumber(balance.pending_reserved);
345
+ const calls = optionalNumber(account?.tokens?.today?.calls);
346
+ const parts = [`余额 $${usd(total ?? 0)}`];
347
+ if (pending && pending > 0) parts.push(`待结算 $${usd(pending)}`);
261
348
  if (textOf(tier.code)) {
262
349
  parts.push(
263
350
  [
264
- textOf(tier.code),
265
- textOf(tier.billing_cycle),
351
+ tierLabel(tier.code),
352
+ cycleLabel(tier.billing_cycle),
266
353
  optionalNumber(tier.expires_in_days) === undefined
267
354
  ? undefined
268
- : `${tier.expires_in_days}d left`,
355
+ : `剩余 ${tier.expires_in_days} 天`,
269
356
  ]
270
357
  .filter(Boolean)
271
358
  .join(" · "),
272
359
  );
273
360
  }
361
+ const included = optionalNumber(tier.included_usd_total);
362
+ if (included && included > 0) parts.push(`套餐额度 $${usd(included)}`);
274
363
  // Only a key with a budget envelope has a reservation; the shared pool has none.
275
364
  if (self.envelope_active) {
276
365
  const reserved =
277
366
  (optionalNumber(self.reserved_package_usd) ?? 0) +
278
367
  (optionalNumber(self.reserved_recharge_usd) ?? 0);
279
- if (reserved > 0) parts.push(`this key $${usd(reserved)}`);
368
+ if (reserved > 0) parts.push(`本密钥 $${usd(reserved)}`);
280
369
  }
281
- const calls = optionalNumber(account?.tokens?.today?.calls);
370
+ const requestLimit = optionalNumber(self.daily_request_limit);
371
+ const usdLimit = optionalNumber(self.daily_usd_limit);
372
+ const limits = [
373
+ requestLimit === undefined ? undefined : `${requestLimit} 次/天`,
374
+ usdLimit === undefined ? undefined : `$${usd(usdLimit)}/天`,
375
+ ].filter(Boolean);
376
+ if (limits.length) parts.push(`限额 ${limits.join(",")}`);
282
377
  if (calls !== undefined) {
283
- parts.push(`${calls} call${calls === 1 ? "" : "s"} today`);
378
+ parts.push(`今日 ${calls} 次调用`);
284
379
  }
285
380
  return {
286
- plan: textOf(tier.code) || keyLabel(key),
287
- windows: [{ name: "D.AI balance", display: parts.join(" · ") }],
381
+ plan: tierLabel(tier.code) || keyLabel(key),
382
+ balance: total === undefined ? "" : `$${usd(total)}`,
383
+ windows: [{ name: "D.AI 余额", display: parts.join(" · ") }],
288
384
  };
289
385
  }
290
386
 
@@ -298,14 +394,14 @@ async function callLogCard(base, key) {
298
394
  });
299
395
  const total = numberOr(log?.total, 0);
300
396
  const last = Array.isArray(log?.data) ? log.data[0] : undefined;
301
- const parts = [`${total} call${total === 1 ? "" : "s"}`];
397
+ const parts = [`${total} 次调用`];
302
398
  if (last) {
303
399
  const settled = optionalNumber(last.settled_usd);
304
400
  parts.push(
305
401
  [
306
402
  textOf(last.created_at).replace("T", " ").slice(0, 16),
307
403
  textOf(last.model_code),
308
- textOf(last.status),
404
+ statusLabel(last.status),
309
405
  settled === undefined ? undefined : `$${usd(settled)}`,
310
406
  ]
311
407
  .filter(Boolean)
@@ -314,10 +410,158 @@ async function callLogCard(base, key) {
314
410
  }
315
411
  return {
316
412
  plan: keyLabel(key),
317
- windows: [{ name: "D.AI calls", display: parts.join(" · ") }],
413
+ windows: [{ name: "D.AI 调用", display: parts.join(" · ") }],
318
414
  };
319
415
  }
320
416
 
417
+ // ---- media catalog -------------------------------------------------------------
418
+
419
+ /**
420
+ * The flat gateway catalog is the source of truth for executable media
421
+ * models. Unlike capability-docs it carries submit/fetch paths, parameters,
422
+ * and the public tier prices a caller needs before submitting a job.
423
+ */
424
+ async function fetchGatewayModels(base, signal) {
425
+ return fetchJson(`${base}/api/system/public/gateway-models`, { signal });
426
+ }
427
+
428
+ function mediaTierRates(value) {
429
+ return (Array.isArray(value) ? value : []).map((rate) => ({
430
+ tierCode: textOf(rate?.tier_code),
431
+ tierName: textOf(rate?.tier_name),
432
+ chargeType: textOf(rate?.charge_type),
433
+ usdCost: optionalNumber(rate?.usd_cost) ?? 0,
434
+ }));
435
+ }
436
+
437
+ function mediaParameters(value) {
438
+ return (Array.isArray(value) ? value : []).map((parameter) => {
439
+ const defaultValue = parameter?.default_value;
440
+ return {
441
+ key: textOf(parameter?.key),
442
+ label: textOf(parameter?.label),
443
+ kind: textOf(parameter?.kind),
444
+ description: textOf(parameter?.description),
445
+ required: Boolean(parameter?.required),
446
+ defaultValue:
447
+ typeof defaultValue === "string" ||
448
+ typeof defaultValue === "number" ||
449
+ typeof defaultValue === "boolean"
450
+ ? defaultValue
451
+ : null,
452
+ placeholder: textOf(parameter?.placeholder),
453
+ min: optionalNumber(parameter?.min) ?? null,
454
+ max: optionalNumber(parameter?.max) ?? null,
455
+ step: optionalNumber(parameter?.step) ?? null,
456
+ options: (Array.isArray(parameter?.options) ? parameter.options : []).map((option) => ({
457
+ label: textOf(option?.label),
458
+ value: textOf(option?.value),
459
+ })),
460
+ };
461
+ });
462
+ }
463
+
464
+ function mediaRows(catalog) {
465
+ const rows = [];
466
+ for (const item of Array.isArray(catalog?.data) ? catalog.data : []) {
467
+ const kind = textOf(item?.capability_type).toLowerCase();
468
+ const modelCode = textOf(item?.model_code);
469
+ if (!MEDIA_KINDS.has(kind) || !modelCode) continue;
470
+ rows.push({
471
+ kind,
472
+ capabilityCode: textOf(item?.capability_code),
473
+ capabilityName: textOf(item?.capability_name),
474
+ modelId: textOf(item?.model_id),
475
+ modelCode,
476
+ serviceCode: textOf(item?.service_code),
477
+ displayName: textOf(item?.display_name) || modelCode,
478
+ description: textOf(item?.description),
479
+ submitPath: textOf(item?.submit_path),
480
+ fetchPath: textOf(item?.fetch_path),
481
+ resultKind: textOf(item?.result_kind),
482
+ promptLabel: textOf(item?.prompt_label),
483
+ promptPlaceholder: textOf(item?.prompt_placeholder),
484
+ promptRequired: Boolean(item?.prompt_required),
485
+ consumptionUnit: textOf(item?.consumption_unit) || "per_request",
486
+ durationSeconds: optionalNumber(item?.duration_seconds) ?? null,
487
+ tierRates: mediaTierRates(item?.tier_rates),
488
+ parameters: mediaParameters(item?.parameters),
489
+ });
490
+ }
491
+ return rows;
492
+ }
493
+
494
+ function mediaModel(row) {
495
+ return {
496
+ kind: row.kind,
497
+ capabilityCode: row.capabilityCode,
498
+ capabilityName: row.capabilityName,
499
+ modelId: row.modelId,
500
+ modelCode: row.modelCode,
501
+ serviceCode: row.serviceCode || null,
502
+ displayName: row.displayName,
503
+ description: row.description,
504
+ submitPath: row.submitPath,
505
+ fetchPath: row.fetchPath || null,
506
+ resultKind: row.resultKind,
507
+ promptLabel: row.promptLabel,
508
+ promptPlaceholder: row.promptPlaceholder,
509
+ promptRequired: row.promptRequired,
510
+ consumptionUnit: row.consumptionUnit,
511
+ durationSeconds: row.durationSeconds,
512
+ tierRates: row.tierRates,
513
+ parameters: row.parameters,
514
+ };
515
+ }
516
+
517
+ function mediaLimit(input = {}) {
518
+ return Math.max(
519
+ 1,
520
+ Math.min(100, Math.floor(optionalNumber(input.limit) ?? MEDIA_CATALOG_LIMIT)),
521
+ );
522
+ }
523
+
524
+ function mediaMatches(rows, input = {}) {
525
+ const kind = textOf(input.kind).toLowerCase();
526
+ if (kind && !MEDIA_KINDS.has(kind)) {
527
+ throw new Error("media_catalog kind must be image, video or audio");
528
+ }
529
+ const capability = textOf(input.capabilityCode).toLowerCase();
530
+ const model = textOf(input.model).toLowerCase();
531
+ const service = textOf(input.serviceCode).toLowerCase();
532
+ const terms = textOf(input.query).toLowerCase().split(/\s+/u).filter(Boolean);
533
+ return rows
534
+ .filter((row) => !kind || row.kind === kind)
535
+ .filter((row) => !capability || row.capabilityCode.toLowerCase() === capability)
536
+ .filter((row) => !model || row.modelCode.toLowerCase() === model)
537
+ .filter((row) => !service || row.serviceCode.toLowerCase() === service)
538
+ .filter((row) => {
539
+ if (!terms.length) return true;
540
+ const haystack = [
541
+ row.modelCode,
542
+ row.serviceCode,
543
+ row.capabilityCode,
544
+ row.capabilityName,
545
+ row.displayName,
546
+ row.description,
547
+ ]
548
+ .join(" ")
549
+ .toLowerCase();
550
+ return terms.every((term) => haystack.includes(term));
551
+ });
552
+ }
553
+
554
+ function filterMedia(rows, input = {}) {
555
+ return mediaMatches(rows, input).slice(0, mediaLimit(input));
556
+ }
557
+
558
+ async function mediaCatalog(base, input, signal) {
559
+ const catalog = await fetchGatewayModels(base, signal);
560
+ const matches = mediaMatches(mediaRows(catalog), input);
561
+ const models = matches.slice(0, mediaLimit(input)).map(mediaModel);
562
+ return { models, count: models.length, matched: matches.length };
563
+ }
564
+
321
565
  // ---- plugin --------------------------------------------------------------------
322
566
 
323
567
  const DaiPlugin = async (_input, options = {}) => {
@@ -355,7 +599,7 @@ const DaiPlugin = async (_input, options = {}) => {
355
599
  auth: {
356
600
  provider: PROVIDER,
357
601
 
358
- methods: [{ type: "api", label: "D.AI API key", placeholder: "ddsk_…" }],
602
+ methods: [{ type: "api", label: "D.AI API 密钥", placeholder: "ddsk_…" }],
359
603
 
360
604
  async loader(getAuth) {
361
605
  const auth = await getAuth();
@@ -364,7 +608,7 @@ const DaiPlugin = async (_input, options = {}) => {
364
608
  return {
365
609
  baseURL: apiBase(base),
366
610
  apiKey: key,
367
- headers: { "x-dai-client": "magpie/0.1.0" },
611
+ headers: { "x-dai-client": CLIENT },
368
612
  };
369
613
  },
370
614
 
@@ -377,7 +621,7 @@ const DaiPlugin = async (_input, options = {}) => {
377
621
  const auth = await getAuth();
378
622
  const key = textOf(auth?.key);
379
623
  if (auth?.type !== "api" || !key) {
380
- return { windows: [], error: "D.AI: sign in with an API key" };
624
+ return { windows: [], error: "D.AI:请使用 API 密钥登录" };
381
625
  }
382
626
  try {
383
627
  const account = await fetchJson(`${base}/api/ai/account`, { apiKey: key });
@@ -385,7 +629,7 @@ const DaiPlugin = async (_input, options = {}) => {
385
629
  } catch (error) {
386
630
  const status = error?.status;
387
631
  if (status === 401 || status === 403) {
388
- return { windows: [], error: "D.AI refused the API key", signIn: "expired" };
632
+ return { windows: [], error: "D.AI 拒绝了该 API 密钥", signIn: "expired" };
389
633
  }
390
634
  // A deployment older than the endpoint answers 404. Its call log
391
635
  // is still there, and counting calls beats showing an error.
@@ -412,11 +656,62 @@ const DaiPlugin = async (_input, options = {}) => {
412
656
  try {
413
657
  const models = await catalogFor(key, undefined);
414
658
  return models ?? provider.models;
415
- } catch {
659
+ } catch (error) {
660
+ if (error?.signIn === "expired") throw error;
416
661
  return provider.models;
417
662
  }
418
663
  },
419
664
  },
665
+
666
+ "chat.headers": async (input, output) => {
667
+ if (input?.model?.providerID !== PROVIDER) return;
668
+ const session = textOf(input?.sessionID);
669
+ if (session) output.headers["x-dai-session"] = session;
670
+ },
671
+
672
+ tool: {
673
+ media_catalog: {
674
+ description:
675
+ "List D.AI image, video and audio models published by the public generation gateway. Read-only and free. Returns submit and fetch paths, parameters and tier prices; generation requires a separately authenticated tool.",
676
+ args: {
677
+ type: "object",
678
+ properties: {
679
+ kind: {
680
+ type: "string",
681
+ enum: ["image", "video", "audio"],
682
+ description: "Optional capability type.",
683
+ },
684
+ capabilityCode: {
685
+ type: "string",
686
+ description: "Exact capability code, such as openai_image or gemini_video.",
687
+ },
688
+ model: {
689
+ type: "string",
690
+ description: "Exact model code, such as gpt-image-2.5-flare.",
691
+ },
692
+ serviceCode: {
693
+ type: "string",
694
+ description: "Exact operation code when one model has several, such as midjourney_imagine.",
695
+ },
696
+ query: {
697
+ type: "string",
698
+ description: "Case-insensitive search in model, service, capability and description text.",
699
+ },
700
+ limit: {
701
+ type: "integer",
702
+ minimum: 1,
703
+ maximum: 100,
704
+ default: 20,
705
+ description: "Maximum number of models to return.",
706
+ },
707
+ },
708
+ additionalProperties: false,
709
+ },
710
+ async execute(input, ctx) {
711
+ return mediaCatalog(base, input, ctx?.abort);
712
+ },
713
+ },
714
+ },
420
715
  };
421
716
  };
422
717
 
@@ -425,4 +720,17 @@ const DaiPlugin = async (_input, options = {}) => {
425
720
  export default { id: PROVIDER, server: DaiPlugin };
426
721
 
427
722
  /** For tests and manual probing: the pure pieces, never called as a plugin. */
428
- export const _internal = { buildModel, fallbackCatalog, fetchCatalog, keyLabel, normalizeBase };
723
+ export const _internal = {
724
+ balanceCard,
725
+ buildModel,
726
+ fallbackCatalog,
727
+ fetchCatalog,
728
+ fetchGatewayModels,
729
+ filterMedia,
730
+ keyLabel,
731
+ mediaCatalog,
732
+ mediaMatches,
733
+ mediaModel,
734
+ mediaRows,
735
+ normalizeBase,
736
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@d-ai/magpie",
3
- "version": "0.1.1",
4
- "description": "D.AI (dai.itbug.shop) models in magpie and OpenCode: one ddsk_ API key, chat models from the public catalog, per-key usage",
3
+ "version": "0.1.3",
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",
7
7
  "exports": {
@@ -26,6 +26,7 @@
26
26
  "url": "git+https://github.com/mdddj/d-ai-magpie.git"
27
27
  },
28
28
  "magpie": {
29
+ "maxConcurrency": 4,
29
30
  "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0iZGFpLWNsb3VkLXRpdGxlIGRhaS1jbG91ZC1kZXNjIj4KICA8dGl0bGUgaWQ9ImRhaS1jbG91ZC10aXRsZSI+RC5BSSBjbG91ZCBtYXNjb3Q8L3RpdGxlPgogIDxkZXNjIGlkPSJkYWktY2xvdWQtZGVzYyI+QSBmcmllbmRseSByb3VuZGVkIGNsb3VkIGNoYXJhY3RlciBvbiBhIGJsYWNrIHNxdWlyY2xlLjwvZGVzYz4KICA8cmVjdCB4PSI0IiB5PSI0IiB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgcng9IjM4IiBmaWxsPSIjMDAwMDAwIiAvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDExLjUgOCkgc2NhbGUoLjgyKSI+CiAgICA8cGF0aCBmaWxsPSIjRjVGNUY1IiBkPSJNMjcgMTI0QzE2LjUgMTI0IDggMTE1LjUgOCAxMDVDOCA5NS4zIDE1LjMgODcuMyAyNC43IDg2LjFDMjUuMSA2My4xIDQwIDQ0IDU4LjQgNDRDNzIuMSA0NCA4My45IDUyLjIgODkuMSA2NEMxMDUuNyA2NC4xIDExOSA3Ny41IDExOSA5NEMxMTkgMTEwLjYgMTA1LjYgMTI0IDg5IDEyNEgyN1oiIC8+CiAgICA8Y2lyY2xlIGN4PSI1MSIgY3k9IjkwIiByPSI1IiBmaWxsPSIjMDAwMDAwIiAvPgogICAgPGNpcmNsZSBjeD0iNzgiIGN5PSI5MCIgcj0iNSIgZmlsbD0iIzAwMDAwMCIgLz4KICA8L2c+Cjwvc3ZnPgo="
30
31
  },
31
32
  "publishConfig": {
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 mddd
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.