@d-ai/magpie 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/index.mjs +428 -0
- package/package.json +32 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# D.AI for magpie / OpenCode
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
6
|
+
|
|
7
|
+
One `ddsk_…` API key is one account, and magpie keeps several.
|
|
8
|
+
|
|
9
|
+
## Add it
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
magpie plugin add mdddj/d-ai-magpie # from GitHub (the unofficial list installs this)
|
|
13
|
+
magpie plugin add @d-ai/magpie # from npm, once published
|
|
14
|
+
magpie plugin login dai # asks for the API key
|
|
15
|
+
magpie provider test dai # one tiny call per API
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then pick a model anywhere magpie does: `dai/claude-opus-5-5`,
|
|
19
|
+
`dai/gpt-6-astra`, `dai/deepseek-v4.1-flash`, …
|
|
20
|
+
|
|
21
|
+
The key comes from the D.AI console (`/#/apikeys`). Custom deployments can point
|
|
22
|
+
at another host with `magpie plugin options dai '{"baseUrl":"https://dai.example"}'`
|
|
23
|
+
or `DAI_BASE_URL`.
|
|
24
|
+
|
|
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.
|
|
28
|
+
|
|
29
|
+
## What it exposes
|
|
30
|
+
|
|
31
|
+
15 token-metered chat models, in two APIs:
|
|
32
|
+
|
|
33
|
+
| API | Models | Path |
|
|
34
|
+
| --- | --- | --- |
|
|
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` |
|
|
36
|
+
| Anthropic Messages | `claude-fable-5`, `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5` | `/v1/messages` |
|
|
37
|
+
|
|
38
|
+
The model list and the prices come from D.AI's public catalog on every start
|
|
39
|
+
(`GET /api/system/public/capability-docs`), narrowed to the models that key may
|
|
40
|
+
call (`GET /v1/models`), so a model you are not entitled to never shows up and a
|
|
41
|
+
new model appears without a plugin update. A built-in list is used when D.AI
|
|
42
|
+
cannot be reached while the provider is first configured.
|
|
43
|
+
|
|
44
|
+
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).
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
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.
|
|
54
|
+
|
|
55
|
+
A D.AI key has no plan window — it spends the account's shared wallet — so the
|
|
56
|
+
card reads as a balance, not a quota window. Two keys of one account show the
|
|
57
|
+
same balance but their own reservations and call counts.
|
|
58
|
+
|
|
59
|
+
## Notes
|
|
60
|
+
|
|
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.
|
|
63
|
+
- The usage card needs `GET /api/ai/account` on the D.AI deployment. Against an
|
|
64
|
+
older one (404) the plugin falls back to the key's call log and shows call
|
|
65
|
+
counts instead of a balance.
|
|
66
|
+
- The plugin never rewrites request bodies. Thinking levels are offered as
|
|
67
|
+
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.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// D.AI (dai.itbug.shop) as a magpie / OpenCode provider plugin.
|
|
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
|
|
5
|
+
// (`GET /api/system/public/capability-docs`), narrowed to what the key may
|
|
6
|
+
// actually call (`GET /v1/models`). Chat models run on D.AI's
|
|
7
|
+
// OpenAI-compatible `/v1/chat/completions`, Claude models on `/v1/messages`.
|
|
8
|
+
// `auth.usage` reads the account behind the key (`GET /api/ai/account`): the
|
|
9
|
+
// wallet balance, the tier, and how much this key alone may spend.
|
|
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.
|
|
15
|
+
//
|
|
16
|
+
// Facts the tables below encode (checked 2026-10-15):
|
|
17
|
+
// - The capability codes carrying token-metered chat are
|
|
18
|
+
// `openai_chat_completions` (10 models) and `claude_messages` (5 models).
|
|
19
|
+
// - `/v1/models` takes the API key as `Authorization: Bearer` or `x-api-key`
|
|
20
|
+
// and lists only the models that key may use.
|
|
21
|
+
// - `prepay_completion_token_limit` is D.AI's completion cap and the value a
|
|
22
|
+
// client should send as its max output tokens (`sdks/pi/src/discover.ts`).
|
|
23
|
+
// - Thinking levels and image input mirror `sdks/pi/src/thinking.ts`, the
|
|
24
|
+
// only place these were verified against the upstreams behind D.AI. A model
|
|
25
|
+
// absent from both tables is declared text-only without thinking levels
|
|
26
|
+
// until someone checks it. D.AI's own catalog is not consulted for either:
|
|
27
|
+
// it carries no per-model reasoning or modality metadata.
|
|
28
|
+
//
|
|
29
|
+
// Sections: constants · helpers · catalog · plugin.
|
|
30
|
+
|
|
31
|
+
const PROVIDER = "dai";
|
|
32
|
+
const DEFAULT_BASE = "https://dai.itbug.shop";
|
|
33
|
+
const OPENAI_NPM = "@ai-sdk/openai-compatible";
|
|
34
|
+
const ANTHROPIC_NPM = "@ai-sdk/anthropic";
|
|
35
|
+
const CATALOG_TTL_MS = 5 * 60 * 1000;
|
|
36
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
37
|
+
|
|
38
|
+
/** Capability codes whose models are token-metered chat models. */
|
|
39
|
+
const CHAT_CAPABILITIES = new Set(["openai_chat_completions", "claude_messages"]);
|
|
40
|
+
|
|
41
|
+
/** Thinking levels per model, verified against pi's tables. Absent = none offered. */
|
|
42
|
+
const VARIANTS = {
|
|
43
|
+
// GPT-5.6: `off` is `none`, no `minimal`; the upstream takes the rest.
|
|
44
|
+
"gpt-5.6-luna": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
45
|
+
"gpt-5.6-sol": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
46
|
+
"gpt-5.6-terra": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
47
|
+
// GPT-6 cannot turn thinking off.
|
|
48
|
+
"gpt-6-astra": ["low", "medium", "high", "xhigh", "max"],
|
|
49
|
+
"gpt-6-sol": ["low", "medium", "high", "xhigh", "max"],
|
|
50
|
+
// River rejects minimal, medium and xhigh on DeepSeek-V4.1-Flash; `none` is off.
|
|
51
|
+
"deepseek-v4.1-flash": ["none", "low", "high", "max"],
|
|
52
|
+
// GLM-5.3-Flash cannot disable reasoning; River also rejects minimal/medium/xhigh.
|
|
53
|
+
"glm-5.3-flash": ["low", "high", "max"],
|
|
54
|
+
// Claude: the adaptive-effort models take xhigh/max, Opus 5.5 the full range.
|
|
55
|
+
"claude-fable-5": ["xhigh", "max"],
|
|
56
|
+
"claude-opus-4-7": ["xhigh", "max"],
|
|
57
|
+
"claude-opus-4-8": ["xhigh", "max"],
|
|
58
|
+
"claude-opus-5": ["xhigh", "max"],
|
|
59
|
+
"claude-opus-5-5": ["low", "medium", "high", "xhigh", "max"],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
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.
|
|
67
|
+
*/
|
|
68
|
+
const VISION = /^(?:gpt-5|gpt-6|deepseek-|glm-|claude-)/u;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Offline fallback, in the shape the network path builds, for a magpie that
|
|
72
|
+
* cannot reach D.AI while the provider is first configured.
|
|
73
|
+
* `[id, context, output, input USD/1M, output USD/1M, cache-read USD/1M]`.
|
|
74
|
+
*/
|
|
75
|
+
const FALLBACK_ROWS = [
|
|
76
|
+
["deepseek-v4.1-flash", 1_048_576, 128_000, 0.15, 0.6, 0.003],
|
|
77
|
+
["gemini-3.8-flash", 1_050_000, 128_000, 0.5, 3, 0.05],
|
|
78
|
+
["glm-5.3-flash", 1_048_576, 128_000, 0.15, 0.5, 0.03],
|
|
79
|
+
["gpt-5.6-luna", 1_050_000, 128_000, 0.2, 1.2, 0.02],
|
|
80
|
+
["gpt-5.6-sol", 1_050_000, 128_000, 5, 30, 0.5],
|
|
81
|
+
["gpt-5.6-terra", 1_050_000, 128_000, 2, 12, 0.2],
|
|
82
|
+
["gpt-6-astra", 1_050_000, 128_000, 10, 50, 1],
|
|
83
|
+
["gpt-6-sol", 1_050_000, 128_000, 2, 10, 0.2],
|
|
84
|
+
["grok-4.6", 500_000, 128_000, 1.5, 5, 0.4],
|
|
85
|
+
["grok-4.7", 500_000, 128_000, 1.5, 5, 0.4],
|
|
86
|
+
["claude-fable-5", 1_000_000, 128_000, 20, 100, 2],
|
|
87
|
+
["claude-opus-4-7", 1_000_000, 128_000, 10, 50, 1],
|
|
88
|
+
["claude-opus-4-8", 1_000_000, 128_000, 10, 50, 1],
|
|
89
|
+
["claude-opus-5", 1_000_000, 128_000, 10, 50, 1],
|
|
90
|
+
["claude-opus-5-5", 1_000_000, 128_000, 8, 40, 0.4],
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// ---- helpers -------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const textOf = (value) => (typeof value === "string" && value.trim() ? value.trim() : "");
|
|
96
|
+
const numberOr = (value, fallback) =>
|
|
97
|
+
typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
98
|
+
const optionalNumber = (value) =>
|
|
99
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
100
|
+
const positiveNumber = (value) =>
|
|
101
|
+
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A USD amount as magpie's cards say one: whole dollars plain, otherwise the
|
|
105
|
+
* significant decimals without a trail of zeros ($86.952653 -> "86.952653").
|
|
106
|
+
* Six places is D.AI's scale, so nothing is rounded away that matters.
|
|
107
|
+
*/
|
|
108
|
+
function usd(value) {
|
|
109
|
+
const n = Number(value);
|
|
110
|
+
if (!Number.isFinite(n)) return "0";
|
|
111
|
+
return n.toFixed(6).replace(/\.?0+$/u, "") || "0";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The base URL with no trailing slash and no trailing `/v1`. */
|
|
115
|
+
function normalizeBase(raw) {
|
|
116
|
+
const value = textOf(raw);
|
|
117
|
+
if (!value) return undefined;
|
|
118
|
+
return value.replace(/\/+$/u, "").replace(/\/v1$/u, "");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const apiBase = (base) => `${base}/v1`;
|
|
122
|
+
|
|
123
|
+
const isClaudeModel = (id) => id.startsWith("claude-");
|
|
124
|
+
|
|
125
|
+
/** `ddsk_01ABC_secret` → what a card may show, never the secret itself. */
|
|
126
|
+
function keyLabel(key) {
|
|
127
|
+
const id = textOf(key).replace(/^ddsk_/u, "").split("_")[0];
|
|
128
|
+
return id ? `key ${id}` : "API key";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function variantsOf(id) {
|
|
132
|
+
const levels =
|
|
133
|
+
VARIANTS[id] ??
|
|
134
|
+
(/^gpt-5\.6/u.test(id)
|
|
135
|
+
? VARIANTS["gpt-5.6-luna"]
|
|
136
|
+
: /^gpt-6/u.test(id)
|
|
137
|
+
? VARIANTS["gpt-6-astra"]
|
|
138
|
+
: undefined);
|
|
139
|
+
if (!levels) return undefined;
|
|
140
|
+
return Object.fromEntries(levels.map((level) => [level, {}]));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* An OpenCode `Model`, which is also what magpie's `config` hook wants. Both
|
|
145
|
+
* `api` and `provider` are written because the two documents disagree on which
|
|
146
|
+
* one carries a per-model API override; they hold the same values, so either
|
|
147
|
+
* reader sees the same thing.
|
|
148
|
+
*/
|
|
149
|
+
function buildModel(id, base, rate = {}) {
|
|
150
|
+
const rules = rate.billing_rules ?? {};
|
|
151
|
+
const vision = VISION.test(id);
|
|
152
|
+
const variants = variantsOf(id);
|
|
153
|
+
const npm = isClaudeModel(id) ? ANTHROPIC_NPM : OPENAI_NPM;
|
|
154
|
+
const cost = {
|
|
155
|
+
input: numberOr(rate.input_usd_per_1m_tokens, 0),
|
|
156
|
+
output: numberOr(rate.output_usd_per_1m_tokens, 0),
|
|
157
|
+
};
|
|
158
|
+
const cacheRead = positiveNumber(rules.cache_read_usd_per_1m_tokens);
|
|
159
|
+
if (cacheRead !== undefined) cost.cache_read = cacheRead;
|
|
160
|
+
const cacheWrite = positiveNumber(rules.cache_write_usd_per_1m_tokens);
|
|
161
|
+
if (cacheWrite !== undefined) cost.cache_write = cacheWrite;
|
|
162
|
+
return {
|
|
163
|
+
id,
|
|
164
|
+
name: id,
|
|
165
|
+
api: { id, npm, url: apiBase(base) },
|
|
166
|
+
provider: { npm, api: apiBase(base) },
|
|
167
|
+
limit: {
|
|
168
|
+
context: Math.max(1, numberOr(rate.context_window_tokens, 128_000)),
|
|
169
|
+
output: Math.max(1, numberOr(rate.prepay_completion_token_limit, 128_000)),
|
|
170
|
+
},
|
|
171
|
+
capabilities: {
|
|
172
|
+
reasoning: Boolean(variants) || vision,
|
|
173
|
+
toolcall: true,
|
|
174
|
+
attachment: vision,
|
|
175
|
+
input: vision ? { text: true, image: true } : { text: true },
|
|
176
|
+
},
|
|
177
|
+
modalities: { input: vision ? ["text", "image"] : ["text"] },
|
|
178
|
+
cost,
|
|
179
|
+
...(variants ? { variants } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function fallbackCatalog(base) {
|
|
184
|
+
const models = {};
|
|
185
|
+
for (const [id, context, output, input, out, cacheRead] of FALLBACK_ROWS) {
|
|
186
|
+
models[id] = buildModel(id, base, {
|
|
187
|
+
context_window_tokens: context,
|
|
188
|
+
prepay_completion_token_limit: output,
|
|
189
|
+
input_usd_per_1m_tokens: input,
|
|
190
|
+
output_usd_per_1m_tokens: out,
|
|
191
|
+
billing_rules: { cache_read_usd_per_1m_tokens: cacheRead },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return models;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- catalog -------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
async function fetchJson(url, { apiKey, signal } = {}) {
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
const onAbort = () => controller.abort();
|
|
202
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
203
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
204
|
+
const headers = new Headers();
|
|
205
|
+
if (apiKey) {
|
|
206
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
207
|
+
headers.set("x-api-key", apiKey);
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetch(url, { headers, signal: controller.signal });
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
throw Object.assign(new Error(`D.AI answered ${res.status}`), { status: res.status });
|
|
213
|
+
}
|
|
214
|
+
return await res.json();
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
signal?.removeEventListener("abort", onAbort);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Every chat model D.AI publishes, keyed by model id. With a key the catalog is
|
|
223
|
+
* narrowed to the models that key may call; a `/v1/models` failure leaves the
|
|
224
|
+
* full catalog in place rather than hiding every model.
|
|
225
|
+
*/
|
|
226
|
+
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
|
+
}
|
|
237
|
+
if (!key || !Object.keys(models).length) return models;
|
|
238
|
+
try {
|
|
239
|
+
const listed = await fetchJson(`${apiBase(base)}/models`, { apiKey: key, signal });
|
|
240
|
+
const allowed = new Set(
|
|
241
|
+
(listed?.data ?? []).map((model) => textOf(model?.id)).filter(Boolean),
|
|
242
|
+
);
|
|
243
|
+
if (!allowed.size) return models;
|
|
244
|
+
return Object.fromEntries(Object.entries(models).filter(([id]) => allowed.has(id)));
|
|
245
|
+
} catch {
|
|
246
|
+
return models;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- the usage card ------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What `GET /api/ai/account` says, as magpie's card: the wallet's total, then
|
|
254
|
+
* the tier, this key's own reservation, and today's calls.
|
|
255
|
+
*/
|
|
256
|
+
function balanceCard(account, key) {
|
|
257
|
+
const balance = account?.balance ?? {};
|
|
258
|
+
const self = account?.api_key ?? {};
|
|
259
|
+
const tier = account?.tier ?? {};
|
|
260
|
+
const parts = [`$${usd(optionalNumber(balance.total) ?? 0)} left`];
|
|
261
|
+
if (textOf(tier.code)) {
|
|
262
|
+
parts.push(
|
|
263
|
+
[
|
|
264
|
+
textOf(tier.code),
|
|
265
|
+
textOf(tier.billing_cycle),
|
|
266
|
+
optionalNumber(tier.expires_in_days) === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: `${tier.expires_in_days}d left`,
|
|
269
|
+
]
|
|
270
|
+
.filter(Boolean)
|
|
271
|
+
.join(" · "),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
// Only a key with a budget envelope has a reservation; the shared pool has none.
|
|
275
|
+
if (self.envelope_active) {
|
|
276
|
+
const reserved =
|
|
277
|
+
(optionalNumber(self.reserved_package_usd) ?? 0) +
|
|
278
|
+
(optionalNumber(self.reserved_recharge_usd) ?? 0);
|
|
279
|
+
if (reserved > 0) parts.push(`this key $${usd(reserved)}`);
|
|
280
|
+
}
|
|
281
|
+
const calls = optionalNumber(account?.tokens?.today?.calls);
|
|
282
|
+
if (calls !== undefined) {
|
|
283
|
+
parts.push(`${calls} call${calls === 1 ? "" : "s"} today`);
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
plan: textOf(tier.code) || keyLabel(key),
|
|
287
|
+
windows: [{ name: "D.AI balance", display: parts.join(" · ") }],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The older card, for a D.AI that has not been updated yet: the key's own call
|
|
293
|
+
* log (`GET /api/ai/usage-logs/api-key`) counts calls but knows no balance.
|
|
294
|
+
*/
|
|
295
|
+
async function callLogCard(base, key) {
|
|
296
|
+
const log = await fetchJson(`${base}/api/ai/usage-logs/api-key?page=1&page_size=1`, {
|
|
297
|
+
apiKey: key,
|
|
298
|
+
});
|
|
299
|
+
const total = numberOr(log?.total, 0);
|
|
300
|
+
const last = Array.isArray(log?.data) ? log.data[0] : undefined;
|
|
301
|
+
const parts = [`${total} call${total === 1 ? "" : "s"}`];
|
|
302
|
+
if (last) {
|
|
303
|
+
const settled = optionalNumber(last.settled_usd);
|
|
304
|
+
parts.push(
|
|
305
|
+
[
|
|
306
|
+
textOf(last.created_at).replace("T", " ").slice(0, 16),
|
|
307
|
+
textOf(last.model_code),
|
|
308
|
+
textOf(last.status),
|
|
309
|
+
settled === undefined ? undefined : `$${usd(settled)}`,
|
|
310
|
+
]
|
|
311
|
+
.filter(Boolean)
|
|
312
|
+
.join(" · "),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
plan: keyLabel(key),
|
|
317
|
+
windows: [{ name: "D.AI calls", display: parts.join(" · ") }],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- plugin --------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
const DaiPlugin = async (_input, options = {}) => {
|
|
324
|
+
const base = normalizeBase(options?.baseUrl ?? process.env.DAI_BASE_URL) ?? DEFAULT_BASE;
|
|
325
|
+
const offline = fallbackCatalog(base);
|
|
326
|
+
|
|
327
|
+
// One catalog read per account, reused until it goes stale. Keyed by the
|
|
328
|
+
// API key, never module-global: magpie runs every account of a provider
|
|
329
|
+
// through this one plugin instance.
|
|
330
|
+
const catalogs = new Map();
|
|
331
|
+
const catalogFor = async (key, signal) => {
|
|
332
|
+
const cached = catalogs.get(key);
|
|
333
|
+
if (cached && Date.now() - cached.at < CATALOG_TTL_MS) return cached.models;
|
|
334
|
+
const models = await fetchCatalog(base, { key, signal });
|
|
335
|
+
if (Object.keys(models).length) {
|
|
336
|
+
catalogs.set(key, { at: Date.now(), models });
|
|
337
|
+
return models;
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
// Declare the provider so it exists before anyone signs in; `??=` keeps
|
|
344
|
+
// whatever the user configured for it in plugins.json.
|
|
345
|
+
config: async (cfg) => {
|
|
346
|
+
cfg.provider ??= {};
|
|
347
|
+
cfg.provider[PROVIDER] ??= {
|
|
348
|
+
name: "D.AI",
|
|
349
|
+
npm: OPENAI_NPM,
|
|
350
|
+
api: apiBase(base),
|
|
351
|
+
models: offline,
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
auth: {
|
|
356
|
+
provider: PROVIDER,
|
|
357
|
+
|
|
358
|
+
methods: [{ type: "api", label: "D.AI API key", placeholder: "ddsk_…" }],
|
|
359
|
+
|
|
360
|
+
async loader(getAuth) {
|
|
361
|
+
const auth = await getAuth();
|
|
362
|
+
const key = textOf(auth?.key);
|
|
363
|
+
if (auth?.type !== "api" || !key) return {};
|
|
364
|
+
return {
|
|
365
|
+
baseURL: apiBase(base),
|
|
366
|
+
apiKey: key,
|
|
367
|
+
headers: { "x-dai-client": "magpie/0.1.0" },
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// The account behind the key: wallet balance, tier and token totals,
|
|
372
|
+
// from D.AI's own endpoint (`GET /api/ai/account`). A `ddsk_` key may
|
|
373
|
+
// call it, which is the point: the plugin can show what a D.AI key
|
|
374
|
+
// cannot otherwise read. The card says the balance as the wallet's
|
|
375
|
+
// total, then how much of it this key may spend and what it has used.
|
|
376
|
+
async usage(getAuth) {
|
|
377
|
+
const auth = await getAuth();
|
|
378
|
+
const key = textOf(auth?.key);
|
|
379
|
+
if (auth?.type !== "api" || !key) {
|
|
380
|
+
return { windows: [], error: "D.AI: sign in with an API key" };
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const account = await fetchJson(`${base}/api/ai/account`, { apiKey: key });
|
|
384
|
+
return balanceCard(account, key);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
const status = error?.status;
|
|
387
|
+
if (status === 401 || status === 403) {
|
|
388
|
+
return { windows: [], error: "D.AI refused the API key", signIn: "expired" };
|
|
389
|
+
}
|
|
390
|
+
// A deployment older than the endpoint answers 404. Its call log
|
|
391
|
+
// is still there, and counting calls beats showing an error.
|
|
392
|
+
if (status === 404) {
|
|
393
|
+
try {
|
|
394
|
+
return await callLogCard(base, key);
|
|
395
|
+
} catch (fallbackError) {
|
|
396
|
+
return { windows: [], error: String(fallbackError?.message ?? fallbackError) };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { windows: [], error: String(error?.message ?? error) };
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
provider: {
|
|
405
|
+
id: PROVIDER,
|
|
406
|
+
|
|
407
|
+
// The account's own list when D.AI answers, else the catalog the
|
|
408
|
+
// config hook declared, so a failed fetch never empties the provider.
|
|
409
|
+
async models(provider, { auth } = {}) {
|
|
410
|
+
const key = textOf(auth?.key);
|
|
411
|
+
if (!key) return provider.models;
|
|
412
|
+
try {
|
|
413
|
+
const models = await catalogFor(key, undefined);
|
|
414
|
+
return models ?? provider.models;
|
|
415
|
+
} catch {
|
|
416
|
+
return provider.models;
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Only the plugin may be a function export: magpie calls every exported
|
|
424
|
+
// function as a plugin, and an OpenCode v1 module is `{ id, server }` anyway.
|
|
425
|
+
export default { id: PROVIDER, server: DaiPlugin };
|
|
426
|
+
|
|
427
|
+
/** For tests and manual probing: the pure pieces, never called as a plugin. */
|
|
428
|
+
export const _internal = { buildModel, fallbackCatalog, fetchCatalog, keyLabel, normalizeBase };
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d-ai/magpie",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.mjs",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"opencode",
|
|
16
|
+
"opencode-plugin",
|
|
17
|
+
"magpie",
|
|
18
|
+
"magpie-plugin",
|
|
19
|
+
"dai",
|
|
20
|
+
"d-ai"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"homepage": "https://dai.itbug.shop",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/mdddj/d-ai-magpie.git"
|
|
27
|
+
},
|
|
28
|
+
"magpie": {
|
|
29
|
+
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0iZGFpLWNsb3VkLXRpdGxlIGRhaS1jbG91ZC1kZXNjIj4KICA8dGl0bGUgaWQ9ImRhaS1jbG91ZC10aXRsZSI+RC5BSSBjbG91ZCBtYXNjb3Q8L3RpdGxlPgogIDxkZXNjIGlkPSJkYWktY2xvdWQtZGVzYyI+QSBmcmllbmRseSByb3VuZGVkIGNsb3VkIGNoYXJhY3RlciBvbiBhIGJsYWNrIHNxdWlyY2xlLjwvZGVzYz4KICA8cmVjdCB4PSI0IiB5PSI0IiB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgcng9IjM4IiBmaWxsPSIjMDAwMDAwIiAvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDExLjUgOCkgc2NhbGUoLjgyKSI+CiAgICA8cGF0aCBmaWxsPSIjRjVGNUY1IiBkPSJNMjcgMTI0QzE2LjUgMTI0IDggMTE1LjUgOCAxMDVDOCA5NS4zIDE1LjMgODcuMyAyNC43IDg2LjFDMjUuMSA2My4xIDQwIDQ0IDU4LjQgNDRDNzIuMSA0NCA4My45IDUyLjIgODkuMSA2NEMxMDUuNyA2NC4xIDExOSA3Ny41IDExOSA5NEMxMTkgMTEwLjYgMTA1LjYgMTI0IDg5IDEyNEgyN1oiIC8+CiAgICA8Y2lyY2xlIGN4PSI1MSIgY3k9IjkwIiByPSI1IiBmaWxsPSIjMDAwMDAwIiAvPgogICAgPGNpcmNsZSBjeD0iNzgiIGN5PSI5MCIgcj0iNSIgZmlsbD0iIzAwMDAwMCIgLz4KICA8L2c+Cjwvc3ZnPgo="
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|