dsh-multi-provider-usage 0.0.0-stage → 0.7.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 +380 -2
- package/README.zh.md +341 -0
- package/cordis.patch.yml +15 -0
- package/docs/widget.png +0 -0
- package/lib/auto-update.js +559 -0
- package/lib/client.js +1753 -0
- package/lib/index.js +1278 -0
- package/lib/opencode-console.js +321 -0
- package/lib/opencode-session.js +135 -0
- package/lib/providers.js +902 -0
- package/lib/util.js +27 -0
- package/package.json +64 -4
package/lib/providers.js
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* dsh-multi-provider-usage — provider adapters.
|
|
4
|
+
*
|
|
5
|
+
* Every adapter turns one subscription's vendor API into the same canonical
|
|
6
|
+
* shape, so the host routes and the browser widget never need provider-specific
|
|
7
|
+
* branches:
|
|
8
|
+
*
|
|
9
|
+
* {
|
|
10
|
+
* id, name, short,
|
|
11
|
+
* status: "ok" | "error",
|
|
12
|
+
* account: { name, ref, id } | null,
|
|
13
|
+
* plan: { id, name, status, periodStart, periodEnd, totalCredits } | null,
|
|
14
|
+
* source: string, // credential ref / auth file / vendor path
|
|
15
|
+
* requested: string | null, // for the OpenCode GO integration fallback
|
|
16
|
+
* fallback: { from, reason, message } | null,
|
|
17
|
+
* windows: [{
|
|
18
|
+
* key, labelKey, hintKey, percent, status, resetsAt, windowMs, note
|
|
19
|
+
* }],
|
|
20
|
+
* meta: object
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* The adapters are dependency-injected (`fetchImpl`, `resolveCredential`,
|
|
24
|
+
* `service`) so the hermetic tests can run them without network or real keys.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { isCredentialRefName, uniqueStrings } from "./util.js";
|
|
31
|
+
import { readOpencodeBalance } from "./opencode-console.js";
|
|
32
|
+
|
|
33
|
+
// ── provider catalogue ─────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Stable provider ids, in display order. */
|
|
36
|
+
export const PROVIDER_IDS = ["opencode-go", "command-code", "glm"];
|
|
37
|
+
|
|
38
|
+
/** Display metadata for the widget tabs and the settings page. */
|
|
39
|
+
export const PROVIDERS = [
|
|
40
|
+
{ id: "opencode-go", name: "OpenCode GO", short: "GO", order: 0 },
|
|
41
|
+
{ id: "command-code", name: "Command Code", short: "CC", order: 1 },
|
|
42
|
+
{ id: "glm", name: "GLM Code Plan", short: "GLM", order: 2 }
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** One provider's display metadata by id. */
|
|
46
|
+
export function providerInfo(id) {
|
|
47
|
+
return PROVIDERS.find((provider) => provider.id === id) ?? { id, name: id, short: id.toUpperCase(), order: 99 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The plan windows each provider can expose. `windowMs` is the window LENGTH
|
|
52
|
+
* used by the client's time bar; adapters may override it (Command Code's
|
|
53
|
+
* monthly window is the real billing period).
|
|
54
|
+
*/
|
|
55
|
+
export const WINDOW_DEFS = {
|
|
56
|
+
rolling: { labelKey: "window.five-hour", hintKey: "window.five-hour.hint", windowMs: 5 * 3600e3 },
|
|
57
|
+
"five-hour": { labelKey: "window.five-hour", hintKey: "window.five-hour.hint", windowMs: 5 * 3600e3 },
|
|
58
|
+
weekly: { labelKey: "window.weekly", hintKey: null, windowMs: 7 * 86400e3 },
|
|
59
|
+
monthly: { labelKey: "window.monthly", hintKey: null, windowMs: 30 * 86400e3 },
|
|
60
|
+
mcp: { labelKey: "window.mcp", hintKey: "window.mcp.hint", windowMs: null },
|
|
61
|
+
token: { labelKey: "window.token", hintKey: null, windowMs: null },
|
|
62
|
+
other: { labelKey: "window.other", hintKey: null, windowMs: null }
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Canonical window key for the alias spellings vendors use. */
|
|
66
|
+
export function canonicalWindowKey(key) {
|
|
67
|
+
if (key === "fiveHour" || key === "five-hour") return "five-hour";
|
|
68
|
+
return key;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── small parsing helpers ──────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** Finite number or null. */
|
|
74
|
+
export function finiteNumber(value) {
|
|
75
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Clamp a percentage into [0, 100]; null stays null. */
|
|
79
|
+
export function clampPercent(value) {
|
|
80
|
+
const number = finiteNumber(value);
|
|
81
|
+
if (number === null) return null;
|
|
82
|
+
return Math.max(0, Math.min(100, number));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** ISO string for a Date, epoch-ms number, or date string; null when invalid/zero. */
|
|
86
|
+
export function toIso(value) {
|
|
87
|
+
if (value === null || value === undefined) return null;
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
const trimmed = value.trim();
|
|
90
|
+
if (trimmed === "") return null;
|
|
91
|
+
const ms = Date.parse(trimmed);
|
|
92
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
93
|
+
}
|
|
94
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
95
|
+
// Vendor timestamps are milliseconds; accept seconds defensively.
|
|
96
|
+
const ms = value < 10_000_000_000 ? value * 1000 : value;
|
|
97
|
+
return new Date(ms).toISOString();
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Build one canonical window, omitting fields the vendor did not send. */
|
|
103
|
+
export function makeWindow(key, percent, options = {}) {
|
|
104
|
+
const canonical = canonicalWindowKey(key);
|
|
105
|
+
const def = WINDOW_DEFS[canonical] ?? WINDOW_DEFS.other;
|
|
106
|
+
const value = clampPercent(percent);
|
|
107
|
+
return {
|
|
108
|
+
key: canonical,
|
|
109
|
+
labelKey: options.labelKey ?? def.labelKey,
|
|
110
|
+
hintKey: options.hintKey ?? def.hintKey ?? null,
|
|
111
|
+
percent: value,
|
|
112
|
+
status: typeof options.status === "string" && options.status.length > 0
|
|
113
|
+
? options.status
|
|
114
|
+
: value !== null && value >= 100 ? "exceeded" : "ok",
|
|
115
|
+
resetsAt: toIso(options.resetsAt),
|
|
116
|
+
windowMs: typeof options.windowMs === "number" && Number.isFinite(options.windowMs) && options.windowMs > 0
|
|
117
|
+
? options.windowMs
|
|
118
|
+
: options.windowMs === null ? null : def.windowMs ?? null,
|
|
119
|
+
...(typeof options.note === "string" && options.note.length > 0 ? { note: options.note } : {})
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Provider failure with a machine-readable code and optional public details. */
|
|
124
|
+
export class ProviderError extends Error {
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} code - stable diagnostic code (`no-credential`, `http-error`, …).
|
|
127
|
+
* @param {string} message - human-readable, already safe to show.
|
|
128
|
+
* @param {Record<string, unknown>} [details] - public fields merged onto the error result.
|
|
129
|
+
*/
|
|
130
|
+
constructor(code, message, details = {}) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = "ProviderError";
|
|
133
|
+
this.code = code;
|
|
134
|
+
Object.assign(this, details);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** JSON fetch with a timeout and a bounded error body. */
|
|
139
|
+
async function fetchJson(fetchImpl, url, init = {}, label = "request") {
|
|
140
|
+
let res;
|
|
141
|
+
try {
|
|
142
|
+
res = await fetchImpl(url, {
|
|
143
|
+
...init,
|
|
144
|
+
signal: init.signal ?? AbortSignal.timeout(15_000)
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
throw new ProviderError("network-error", `${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
148
|
+
}
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const body = await res.text().catch(() => "");
|
|
151
|
+
throw new ProviderError("http-error", `${label} ${res.status}: ${body.slice(0, 300)}`);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return await res.json();
|
|
155
|
+
} catch {
|
|
156
|
+
throw new ProviderError("invalid-response", `${label}: response is not JSON`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── OpenCode GO ────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export const DEFAULT_OPENCODE_BASE_URL = "https://opencode.ai/zen/go";
|
|
163
|
+
export const DEFAULT_OPENCODE_API_KEY_ENV = "OPENCODE_GO_API_KEY";
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate and canonicalize the gateway's `{ usage: { rolling, weekly, monthly } }`
|
|
167
|
+
* payload (also accepts the flat shape `dsh-opencode-go`'s service returns).
|
|
168
|
+
* Throws instead of turning absent data into zeroes.
|
|
169
|
+
* @param {unknown} payload
|
|
170
|
+
*/
|
|
171
|
+
export function normalizeOpencodeUsage(payload) {
|
|
172
|
+
const usage = payload !== null && typeof payload === "object" && payload.usage !== null && typeof payload.usage === "object"
|
|
173
|
+
? payload.usage
|
|
174
|
+
: payload;
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const [key, windowKey] of [["rolling", "rolling"], ["weekly", "weekly"], ["monthly", "monthly"]]) {
|
|
177
|
+
const row = usage?.[key];
|
|
178
|
+
if (row === null || typeof row !== "object" || finiteNumber(row.percent) === null) {
|
|
179
|
+
throw new ProviderError("invalid-response", `invalid usage payload: missing ${key}.percent`);
|
|
180
|
+
}
|
|
181
|
+
out.push(makeWindow(windowKey, row.percent, {
|
|
182
|
+
status: typeof row.status === "string" ? row.status : "ok",
|
|
183
|
+
resetsAt: row.resetsAt
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Probe the optional `dsh-opencode-go` integration without a network call.
|
|
191
|
+
* @param {unknown} service - the `opencodeGoUsage` service, or null.
|
|
192
|
+
* @param {{ found?: boolean, enabled?: boolean|null, account?: object, ref?: string, baseURL?: string|null } | null} entry
|
|
193
|
+
*/
|
|
194
|
+
export function probeOpencodeGo(service, entry) {
|
|
195
|
+
if (!entry || entry.found !== true) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
reason: "not-installed",
|
|
199
|
+
message: "dsh-opencode-go is not installed, or its profile entry is inactive",
|
|
200
|
+
entryFound: false,
|
|
201
|
+
enabled: null,
|
|
202
|
+
service: service !== null && service !== undefined
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (entry.enabled === false) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: "entry-disabled",
|
|
209
|
+
message: "dsh-opencode-go is installed but disabled in this profile",
|
|
210
|
+
entryFound: true,
|
|
211
|
+
enabled: false,
|
|
212
|
+
service: service !== null && service !== undefined
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (service === null || service === undefined || typeof service.read !== "function") {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: "service-unavailable",
|
|
219
|
+
message: "dsh-opencode-go does not expose the opencodeGoUsage service (unsupported version)",
|
|
220
|
+
entryFound: true,
|
|
221
|
+
enabled: true,
|
|
222
|
+
service: false
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, reason: null, message: null, entryFound: true, enabled: true, service: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Start the optional OpenCode console balance read (never rejects). */
|
|
229
|
+
function startOpencodeBalance(consoleConfig, resolveCredential, fetchImpl) {
|
|
230
|
+
if (consoleConfig === null || consoleConfig === undefined || consoleConfig.enabled !== true) {
|
|
231
|
+
return Promise.resolve(null);
|
|
232
|
+
}
|
|
233
|
+
return readOpencodeBalance({ config: consoleConfig, resolveCredential, fetchImpl })
|
|
234
|
+
.then((balance) => ({ ok: true, balance }))
|
|
235
|
+
.catch((error) => ({ ok: false, error }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Project a settled balance read into public result fields. */
|
|
239
|
+
function balanceFields(settled) {
|
|
240
|
+
if (settled === null) return { balance: null, balanceError: null };
|
|
241
|
+
if (settled.ok) return { balance: settled.balance, balanceError: null };
|
|
242
|
+
return {
|
|
243
|
+
balance: null,
|
|
244
|
+
balanceError: settled.error instanceof Error ? settled.error.message : String(settled.error)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Merge a settled balance read into one provider result. */
|
|
249
|
+
function attachBalance(result, settled) {
|
|
250
|
+
const fields = balanceFields(settled);
|
|
251
|
+
return {
|
|
252
|
+
...result,
|
|
253
|
+
balance: fields.balance,
|
|
254
|
+
meta: {
|
|
255
|
+
...(result.meta ?? {}),
|
|
256
|
+
...(fields.balanceError === null ? {} : { balanceError: fields.balanceError })
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build the OpenCode GO error result shared by every failure path: the
|
|
263
|
+
* account, the integration probe, the fallback and the settled balance read
|
|
264
|
+
* always travel together so the widget never hides the quota that did arrive.
|
|
265
|
+
*/
|
|
266
|
+
async function opencodeGoFailure(code, message, accountRef, probe, fallback, balancePromise) {
|
|
267
|
+
const balance = balanceFields(await balancePromise);
|
|
268
|
+
return new ProviderError(code, message, {
|
|
269
|
+
account: { ref: accountRef, id: null, name: "" },
|
|
270
|
+
fallback,
|
|
271
|
+
balance: balance.balance,
|
|
272
|
+
meta: { opencodeGo: probe, ...(balance.balanceError === null ? {} : { balanceError: balance.balanceError }) }
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read OpenCode GO quota.
|
|
278
|
+
*
|
|
279
|
+
* `source: "opencode-go"` prefers the live `dsh-opencode-go` service (so the
|
|
280
|
+
* numbers follow its active account), then falls back to the official
|
|
281
|
+
* `${baseUrl}/v1/usage` endpoint with a resolved credential. `source:
|
|
282
|
+
* "builtin"` pins the HTTP path.
|
|
283
|
+
*
|
|
284
|
+
* @param {{
|
|
285
|
+
* config: { source?: string, apiKeyEnv?: string, baseUrl?: string },
|
|
286
|
+
* resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
|
|
287
|
+
* service?: { read?: () => Promise<unknown> } | null,
|
|
288
|
+
* entry?: object | null,
|
|
289
|
+
* fetchImpl?: typeof fetch,
|
|
290
|
+
* }} input
|
|
291
|
+
*/
|
|
292
|
+
export async function readOpencodeGo(input) {
|
|
293
|
+
const { config, resolveCredential, service = null, entry = null, fetchImpl = fetch } = input;
|
|
294
|
+
const source = config.source === "builtin" ? "builtin" : "opencode-go";
|
|
295
|
+
const probe = probeOpencodeGo(service, entry);
|
|
296
|
+
const balancePromise = startOpencodeBalance(config.console, resolveCredential, fetchImpl);
|
|
297
|
+
let fallback = null;
|
|
298
|
+
|
|
299
|
+
if (source === "opencode-go") {
|
|
300
|
+
if (probe.ok) {
|
|
301
|
+
let raw;
|
|
302
|
+
try {
|
|
303
|
+
raw = await service.read();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
fallback = {
|
|
306
|
+
from: "opencode-go",
|
|
307
|
+
reason: "read-failed",
|
|
308
|
+
message: error instanceof Error ? error.message : String(error)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (fallback === null) {
|
|
312
|
+
try {
|
|
313
|
+
const windows = normalizeOpencodeUsage(raw);
|
|
314
|
+
return attachBalance({
|
|
315
|
+
...providerInfo("opencode-go"),
|
|
316
|
+
status: "ok",
|
|
317
|
+
account: entry?.account ?? null,
|
|
318
|
+
plan: null,
|
|
319
|
+
source: "opencode-go",
|
|
320
|
+
requested: "opencode-go",
|
|
321
|
+
fallback: null,
|
|
322
|
+
windows,
|
|
323
|
+
meta: { opencodeGo: { ...probe, compatible: true }, lastSwitch: raw?.lastSwitch ?? null }
|
|
324
|
+
}, await balancePromise);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
fallback = {
|
|
327
|
+
from: "opencode-go",
|
|
328
|
+
reason: "invalid-response",
|
|
329
|
+
message: error instanceof Error ? error.message : String(error)
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
fallback = { from: "opencode-go", reason: probe.reason, message: probe.message };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const refs = uniqueStrings([
|
|
339
|
+
config.apiKeyEnv,
|
|
340
|
+
entry?.enabled === true ? entry.ref : null,
|
|
341
|
+
DEFAULT_OPENCODE_API_KEY_ENV,
|
|
342
|
+
"OPENCODE_API_KEY"
|
|
343
|
+
]).filter(isCredentialRefName);
|
|
344
|
+
const credential = await resolveCredential(refs);
|
|
345
|
+
if (credential === null) {
|
|
346
|
+
const reason = fallback === null ? null : `; ${fallback.reason}: ${fallback.message}`;
|
|
347
|
+
throw await opencodeGoFailure(
|
|
348
|
+
"no-credential",
|
|
349
|
+
`OpenCode GO: no API key (set one of ${refs.join(", ")})${reason ?? ""}`,
|
|
350
|
+
config.apiKeyEnv ?? DEFAULT_OPENCODE_API_KEY_ENV,
|
|
351
|
+
probe,
|
|
352
|
+
fallback,
|
|
353
|
+
balancePromise
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const baseUrl = String(config.baseUrl || entry?.baseURL || DEFAULT_OPENCODE_BASE_URL).replace(/\/+$/, "");
|
|
358
|
+
let payload;
|
|
359
|
+
try {
|
|
360
|
+
payload = await fetchJson(
|
|
361
|
+
fetchImpl,
|
|
362
|
+
`${baseUrl}/v1/usage`,
|
|
363
|
+
{ headers: { Authorization: `Bearer ${credential.value}` } },
|
|
364
|
+
"OpenCode GO usage"
|
|
365
|
+
);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const reason = fallback === null ? "" : ` — dsh-opencode-go: ${fallback.reason}: ${fallback.message}`;
|
|
368
|
+
throw await opencodeGoFailure(
|
|
369
|
+
error instanceof ProviderError ? error.code : "read-failed",
|
|
370
|
+
`${error instanceof Error ? error.message : String(error)}${reason}`,
|
|
371
|
+
credential.ref,
|
|
372
|
+
probe,
|
|
373
|
+
fallback,
|
|
374
|
+
balancePromise
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let windows;
|
|
379
|
+
try {
|
|
380
|
+
windows = normalizeOpencodeUsage(payload);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
throw await opencodeGoFailure(
|
|
383
|
+
"invalid-response",
|
|
384
|
+
error instanceof Error ? error.message : String(error),
|
|
385
|
+
credential.ref,
|
|
386
|
+
probe,
|
|
387
|
+
fallback,
|
|
388
|
+
balancePromise
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return attachBalance({
|
|
393
|
+
...providerInfo("opencode-go"),
|
|
394
|
+
status: "ok",
|
|
395
|
+
// The numbers came from the built-in credential, so this account
|
|
396
|
+
// describes that ref; the dsh-opencode-go probe lives in meta.
|
|
397
|
+
account: { ref: credential.ref, id: null, name: "" },
|
|
398
|
+
plan: null,
|
|
399
|
+
source: "builtin",
|
|
400
|
+
requested: source,
|
|
401
|
+
fallback,
|
|
402
|
+
windows,
|
|
403
|
+
meta: {
|
|
404
|
+
opencodeGo: {
|
|
405
|
+
...probe,
|
|
406
|
+
reason: fallback?.reason ?? probe.reason,
|
|
407
|
+
message: fallback?.message ?? probe.message,
|
|
408
|
+
compatible: fallback === null && probe.ok
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}, await balancePromise);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Command Code ───────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
export const DEFAULT_COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
|
|
417
|
+
export const DEFAULT_COMMAND_CODE_API_KEY_ENV = "COMMANDCODE_API_KEY";
|
|
418
|
+
|
|
419
|
+
/** Plan totals documented by the Command Code CLI (credits). */
|
|
420
|
+
const COMMAND_CODE_PLAN_TOTALS = {
|
|
421
|
+
"individual-go": 10,
|
|
422
|
+
"individual-go-v1": 10,
|
|
423
|
+
"individual-goat": 70,
|
|
424
|
+
"individual-pro": 30,
|
|
425
|
+
"individual-pro-v1": 80,
|
|
426
|
+
"individual-provider": 15,
|
|
427
|
+
"individual-max": 150,
|
|
428
|
+
"individual-ultra": 300,
|
|
429
|
+
"teams-pro": 40
|
|
430
|
+
};
|
|
431
|
+
const COMMAND_CODE_PLAN_NAMES = {
|
|
432
|
+
"individual-go": "Go",
|
|
433
|
+
"individual-go-v1": "Go",
|
|
434
|
+
"individual-goat": "GOAT",
|
|
435
|
+
"individual-pro": "Pro",
|
|
436
|
+
"individual-pro-v1": "Pro",
|
|
437
|
+
"individual-provider": "Provider",
|
|
438
|
+
"individual-max": "Max",
|
|
439
|
+
"individual-ultra": "Ultra",
|
|
440
|
+
"teams-pro": "Teams Pro"
|
|
441
|
+
};
|
|
442
|
+
const COMMAND_CODE_PLAN_IDS = Object.keys(COMMAND_CODE_PLAN_TOTALS).sort((a, b) => b.length - a.length);
|
|
443
|
+
|
|
444
|
+
/** Resolve a subscription plan id to its display name and monthly credit total. */
|
|
445
|
+
export function commandCodePlan(planId) {
|
|
446
|
+
if (typeof planId !== "string" || planId.length === 0) return null;
|
|
447
|
+
const normalized = planId.toLowerCase().replace(/_/g, "-");
|
|
448
|
+
const id = COMMAND_CODE_PLAN_IDS.find((candidate) => normalized.startsWith(candidate));
|
|
449
|
+
if (id === undefined) return { id: normalized, name: planId, totalCredits: null };
|
|
450
|
+
return { id, name: COMMAND_CODE_PLAN_NAMES[id] ?? id, totalCredits: COMMAND_CODE_PLAN_TOTALS[id] ?? null };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Expand `~` and environment variables in a user-supplied path. */
|
|
454
|
+
function expandHome(pathValue) {
|
|
455
|
+
const raw = String(pathValue ?? "").trim();
|
|
456
|
+
if (raw === "") return join(homedir(), ".commandcode", "auth.json");
|
|
457
|
+
if (raw === "~") return homedir();
|
|
458
|
+
if (raw.startsWith("~/")) return join(homedir(), raw.slice(2));
|
|
459
|
+
return raw;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Read a Command Code CLI auth file and return its API key, if any. */
|
|
463
|
+
export function readCommandCodeAuthFile(pathValue) {
|
|
464
|
+
const path = expandHome(pathValue);
|
|
465
|
+
try {
|
|
466
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
467
|
+
for (const key of ["apiKey", "api_key", "token", "accessToken"]) {
|
|
468
|
+
if (typeof parsed?.[key] === "string" && parsed[key].length > 0) {
|
|
469
|
+
return { key: parsed[key], path, userName: typeof parsed.userName === "string" ? parsed.userName : "" };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
// absent / unreadable auth file is the same as "not configured"
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Read Command Code quota from its billing/usage APIs.
|
|
480
|
+
*
|
|
481
|
+
* The CLI's `/alpha/billing/credits` response supplies the 5-hour and weekly
|
|
482
|
+
* windows, `/alpha/billing/subscriptions` supplies the billing period, and
|
|
483
|
+
* `/alpha/usage/summary` supplies period spend. Monthly utilization is
|
|
484
|
+
* `totalCost / (totalCost + remaining credits)`, which is the same relation the
|
|
485
|
+
* official CLI exposes (and what its usage screen displays).
|
|
486
|
+
*
|
|
487
|
+
* @param {{
|
|
488
|
+
* config: { apiKeyEnv?: string, baseUrl?: string, authFile?: string },
|
|
489
|
+
* resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
|
|
490
|
+
* fetchImpl?: typeof fetch,
|
|
491
|
+
* }} input
|
|
492
|
+
*/
|
|
493
|
+
export async function readCommandCode(input) {
|
|
494
|
+
const { config, resolveCredential, fetchImpl = fetch } = input;
|
|
495
|
+
const refs = uniqueStrings([config.apiKeyEnv, DEFAULT_COMMAND_CODE_API_KEY_ENV]).filter(isCredentialRefName);
|
|
496
|
+
const credential = await resolveCredential(refs);
|
|
497
|
+
let auth = null;
|
|
498
|
+
let source = null;
|
|
499
|
+
let accountRef = "";
|
|
500
|
+
if (credential !== null) {
|
|
501
|
+
auth = credential.value;
|
|
502
|
+
source = `credential:${credential.ref}`;
|
|
503
|
+
accountRef = credential.ref;
|
|
504
|
+
} else {
|
|
505
|
+
const file = readCommandCodeAuthFile(config.authFile);
|
|
506
|
+
if (file !== null) {
|
|
507
|
+
auth = file.key;
|
|
508
|
+
source = "auth-file";
|
|
509
|
+
accountRef = file.path;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (auth === null) {
|
|
513
|
+
throw new ProviderError(
|
|
514
|
+
"no-credential",
|
|
515
|
+
`Command Code: no API key (set ${refs[0] ?? DEFAULT_COMMAND_CODE_API_KEY_ENV}, or sign in with the Command Code CLI)`,
|
|
516
|
+
{ account: { ref: refs[0] ?? DEFAULT_COMMAND_CODE_API_KEY_ENV, id: null, name: "" } }
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const baseUrl = String(config.baseUrl || DEFAULT_COMMAND_CODE_BASE_URL).replace(/\/+$/, "");
|
|
521
|
+
const headers = {
|
|
522
|
+
Authorization: `Bearer ${auth}`,
|
|
523
|
+
"Content-Type": "application/json",
|
|
524
|
+
"User-Agent": "dsh-multi-provider-usage/0.1"
|
|
525
|
+
};
|
|
526
|
+
/** @param {string} path */
|
|
527
|
+
const get = async (path) => {
|
|
528
|
+
try {
|
|
529
|
+
return { data: await fetchJson(fetchImpl, baseUrl + path, { headers }, `Command Code ${path.split("?")[0]}`), error: null };
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return { data: null, error: error instanceof Error ? error.message : String(error) };
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const [whoamiResult, subscriptionResult, creditsResult] = await Promise.all([
|
|
536
|
+
get("/alpha/whoami?limits=1"),
|
|
537
|
+
get("/alpha/billing/subscriptions"),
|
|
538
|
+
get("/alpha/billing/credits")
|
|
539
|
+
]);
|
|
540
|
+
const subscription = subscriptionResult.data?.data ?? null;
|
|
541
|
+
const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart : null;
|
|
542
|
+
const summaryResult = await get(`/alpha/usage/summary${periodStart === null ? "" : `?since=${encodeURIComponent(periodStart)}`}`);
|
|
543
|
+
const summary = summaryResult.data ?? null;
|
|
544
|
+
const credits = creditsResult.data?.credits ?? null;
|
|
545
|
+
const windowLimits = creditsResult.data?.windowLimits ?? null;
|
|
546
|
+
|
|
547
|
+
const errors = [whoamiResult.error, subscriptionResult.error, creditsResult.error, summaryResult.error].filter(Boolean);
|
|
548
|
+
if (whoamiResult.data === null && creditsResult.data === null && subscriptionResult.data === null && summaryResult.data === null) {
|
|
549
|
+
throw new ProviderError("read-failed", `Command Code: ${errors.join("; ") || "all billing endpoints failed"}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const whoami = whoamiResult.data ?? null;
|
|
553
|
+
const planId = typeof subscription?.planId === "string" ? subscription.planId : null;
|
|
554
|
+
const planInfo = commandCodePlan(planId);
|
|
555
|
+
const accountName = typeof whoami?.user?.name === "string" && whoami.user.name.length > 0
|
|
556
|
+
? whoami.user.name
|
|
557
|
+
: typeof whoami?.user?.userName === "string" ? whoami.user.userName : "";
|
|
558
|
+
const account = {
|
|
559
|
+
ref: accountRef,
|
|
560
|
+
id: typeof whoami?.user?.id === "string" ? whoami.user.id : null,
|
|
561
|
+
name: accountName
|
|
562
|
+
};
|
|
563
|
+
const plan = planInfo === null ? null : {
|
|
564
|
+
id: planInfo.id,
|
|
565
|
+
name: planInfo.name,
|
|
566
|
+
status: typeof subscription?.status === "string" ? subscription.status : null,
|
|
567
|
+
periodStart,
|
|
568
|
+
periodEnd: typeof subscription?.currentPeriodEnd === "string" ? subscription.currentPeriodEnd : null,
|
|
569
|
+
totalCredits: planInfo.totalCredits
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const windows = [];
|
|
573
|
+
|
|
574
|
+
// Monthly credits: `used` is the billing-period spend and `remaining` is
|
|
575
|
+
// the plan's recurring monthly credit balance. Purchased and free credits
|
|
576
|
+
// are extra top-ups, reported separately (the official CLI's usage screen
|
|
577
|
+
// calls them "Extra Credits"); they are not part of the monthly plan bar.
|
|
578
|
+
const used = finiteNumber(summary?.totalCost);
|
|
579
|
+
const monthlyRemaining = finiteNumber(credits?.monthlyCredits);
|
|
580
|
+
const purchasedRemaining = finiteNumber(credits?.purchasedCredits);
|
|
581
|
+
const freeRemaining = finiteNumber(credits?.freeCredits);
|
|
582
|
+
// The credits endpoint is the only source for the top-up balance. When it
|
|
583
|
+
// fails, or answers without either credit pool, the widget still needs to
|
|
584
|
+
// say why instead of leaving the balance slot blank.
|
|
585
|
+
const balanceError = creditsResult.error === null ? null : `Command Code balance: ${creditsResult.error}`;
|
|
586
|
+
const balanceMissing = creditsResult.error === null && purchasedRemaining === null && freeRemaining === null;
|
|
587
|
+
let monthlyCap = null;
|
|
588
|
+
let monthlyPercent = null;
|
|
589
|
+
if (used !== null && monthlyRemaining !== null) {
|
|
590
|
+
monthlyCap = used + monthlyRemaining;
|
|
591
|
+
monthlyPercent = monthlyCap > 0 ? (used / monthlyCap) * 100 : 0;
|
|
592
|
+
} else if (planInfo !== null && monthlyRemaining !== null && planInfo.totalCredits !== null) {
|
|
593
|
+
monthlyCap = planInfo.totalCredits;
|
|
594
|
+
monthlyPercent = monthlyCap > 0 ? ((monthlyCap - Math.max(0, monthlyRemaining)) / monthlyCap) * 100 : 0;
|
|
595
|
+
}
|
|
596
|
+
let monthlyWindow = null;
|
|
597
|
+
if (monthlyPercent !== null) {
|
|
598
|
+
const start = periodStart === null ? null : Date.parse(periodStart);
|
|
599
|
+
const end = typeof subscription?.currentPeriodEnd === "string" ? Date.parse(subscription.currentPeriodEnd) : null;
|
|
600
|
+
monthlyWindow = makeWindow("monthly", monthlyPercent, {
|
|
601
|
+
status: monthlyPercent >= 100 ? "exceeded" : "ok",
|
|
602
|
+
resetsAt: subscription?.currentPeriodEnd,
|
|
603
|
+
windowMs: Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : null
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 5-hour and weekly rolling windows. `resetAt` is epoch milliseconds and 0
|
|
608
|
+
// when the window has not started / is unlimited.
|
|
609
|
+
for (const [key, label] of [["five-hour", "fiveHour"], ["weekly", "weekly"]]) {
|
|
610
|
+
const limit = windowLimits?.[label];
|
|
611
|
+
if (limit === null || typeof limit !== "object") continue;
|
|
612
|
+
const cap = finiteNumber(limit.cap);
|
|
613
|
+
const windowUsed = finiteNumber(limit.used);
|
|
614
|
+
if (cap === null || windowUsed === null || cap <= 0) continue;
|
|
615
|
+
const percent = (windowUsed / cap) * 100;
|
|
616
|
+
windows.push(makeWindow(key, percent, {
|
|
617
|
+
status: limit.exceeded === true || percent >= 100 ? "exceeded" : "ok",
|
|
618
|
+
resetsAt: limit.resetAt
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
// The monthly billing window always renders last for Command Code.
|
|
622
|
+
if (monthlyWindow !== null) windows.push(monthlyWindow);
|
|
623
|
+
|
|
624
|
+
const extraCredits = purchasedRemaining !== null || freeRemaining !== null
|
|
625
|
+
? (purchasedRemaining ?? 0) + (freeRemaining ?? 0)
|
|
626
|
+
: null;
|
|
627
|
+
const balance = extraCredits === null ? null : {
|
|
628
|
+
amount: extraCredits,
|
|
629
|
+
currency: "USD",
|
|
630
|
+
labelKey: "balance.extra",
|
|
631
|
+
source: "command-code-credits",
|
|
632
|
+
details: { purchased: purchasedRemaining, free: freeRemaining }
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
if (windows.length === 0) {
|
|
636
|
+
// The credits request carries the windows, so a failure there leaves
|
|
637
|
+
// nothing to render; name the underlying error instead of a generic
|
|
638
|
+
// "no usable windows" (the 5xx used to be swallowed into meta.errors).
|
|
639
|
+
const cause = errors.length === 0 ? "" : ` (${errors.join("; ")})`;
|
|
640
|
+
throw new ProviderError("invalid-response", `Command Code: billing responses contained no usable quota windows${cause}`, {
|
|
641
|
+
account,
|
|
642
|
+
plan,
|
|
643
|
+
balance,
|
|
644
|
+
meta: { errors, ...(balanceError === null ? {} : { balanceError }) }
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Useful diagnostics for the settings page and the chat command.
|
|
649
|
+
const totalDays = subscription?.currentPeriodStart && subscription?.currentPeriodEnd
|
|
650
|
+
? (Date.parse(subscription.currentPeriodEnd) - Date.parse(subscription.currentPeriodStart)) / 86400e3
|
|
651
|
+
: null;
|
|
652
|
+
const elapsedDays = subscription?.currentPeriodStart
|
|
653
|
+
? (Date.now() - Date.parse(subscription.currentPeriodStart)) / 86400e3
|
|
654
|
+
: null;
|
|
655
|
+
const dailyRate = used !== null && elapsedDays !== null && elapsedDays > 0 ? used / elapsedDays : null;
|
|
656
|
+
const runsOutInDays = dailyRate !== null && dailyRate > 0 && monthlyRemaining !== null ? monthlyRemaining / dailyRate : null;
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
...providerInfo("command-code"),
|
|
660
|
+
status: "ok",
|
|
661
|
+
account,
|
|
662
|
+
plan,
|
|
663
|
+
source,
|
|
664
|
+
requested: null,
|
|
665
|
+
fallback: null,
|
|
666
|
+
balance,
|
|
667
|
+
windows,
|
|
668
|
+
meta: {
|
|
669
|
+
planId,
|
|
670
|
+
errors,
|
|
671
|
+
...(balanceError === null ? {} : { balanceError }),
|
|
672
|
+
...(balanceMissing ? { balanceMissing: true } : {}),
|
|
673
|
+
monthly: {
|
|
674
|
+
used,
|
|
675
|
+
remaining: monthlyRemaining,
|
|
676
|
+
purchased: purchasedRemaining,
|
|
677
|
+
free: freeRemaining,
|
|
678
|
+
extra: extraCredits,
|
|
679
|
+
cap: monthlyCap,
|
|
680
|
+
percent: monthlyPercent,
|
|
681
|
+
periodBasis: summary?.periodBasis ?? null
|
|
682
|
+
},
|
|
683
|
+
totals: summary === null ? null : {
|
|
684
|
+
requests: summary.totalCount ?? null,
|
|
685
|
+
successRate: summary.successRate ?? null,
|
|
686
|
+
completed: summary.completedCount ?? null,
|
|
687
|
+
failed: summary.failedCount ?? null,
|
|
688
|
+
tokensIn: summary.totalTokensIn ?? null,
|
|
689
|
+
tokensOut: summary.totalTokensOut ?? null
|
|
690
|
+
},
|
|
691
|
+
projection: {
|
|
692
|
+
elapsedDays: Number.isFinite(elapsedDays) ? elapsedDays : null,
|
|
693
|
+
totalDays: Number.isFinite(totalDays) ? totalDays : null,
|
|
694
|
+
dailyRate,
|
|
695
|
+
runsOutInDays
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ── GLM Code Plan (Z.ai / BigModel) ────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
export const DEFAULT_GLM_API_KEY_ENV = "ZAI_API_KEY";
|
|
704
|
+
export const GLM_ZAI_BASE_URL = "https://api.z.ai";
|
|
705
|
+
export const GLM_BIGMODEL_BASE_URL = "https://open.bigmodel.cn";
|
|
706
|
+
export const GLM_KNOWN_REFS = [
|
|
707
|
+
"ZAI_CODING_CN_API_KEY",
|
|
708
|
+
"ZAI_API_KEY",
|
|
709
|
+
"ZHIPU_API_KEY",
|
|
710
|
+
"GLM_API_KEY",
|
|
711
|
+
"BIGMODEL_API_KEY"
|
|
712
|
+
];
|
|
713
|
+
|
|
714
|
+
/** Build the quota URL for a base that may or may not include `/api`. */
|
|
715
|
+
export function glmQuotaUrl(baseUrl) {
|
|
716
|
+
const base = String(baseUrl ?? "").replace(/\/+$/, "");
|
|
717
|
+
return base.endsWith("/api")
|
|
718
|
+
? `${base}/monitor/usage/quota/limit`
|
|
719
|
+
: `${base}/api/monitor/usage/quota/limit`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Candidate base URLs for the configured region, in probe order. */
|
|
723
|
+
export function glmBases(config = {}) {
|
|
724
|
+
const explicit = typeof config.baseUrl === "string" ? config.baseUrl.trim() : "";
|
|
725
|
+
if (config.region === "custom") return explicit.length === 0 ? [] : [explicit.replace(/\/+$/, "")];
|
|
726
|
+
if (explicit.length > 0) return [explicit.replace(/\/+$/, "")];
|
|
727
|
+
const region = config.region === "bigmodel" || config.region === "zai" ? config.region : "auto";
|
|
728
|
+
if (region === "zai") return [GLM_ZAI_BASE_URL];
|
|
729
|
+
if (region === "bigmodel") return [GLM_BIGMODEL_BASE_URL];
|
|
730
|
+
return [GLM_ZAI_BASE_URL, GLM_BIGMODEL_BASE_URL];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Extract the `limits` array from the several envelope shapes the API uses. */
|
|
734
|
+
export function glmLimits(payload) {
|
|
735
|
+
if (Array.isArray(payload?.limits)) return payload.limits;
|
|
736
|
+
if (Array.isArray(payload?.data?.limits)) return payload.data.limits;
|
|
737
|
+
if (Array.isArray(payload?.data?.data?.limits)) return payload.data.data.limits;
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Turn the GLM quota payload into canonical windows.
|
|
743
|
+
*
|
|
744
|
+
* `TOKENS_LIMIT` with `unit: 3, number: 5` is the 5-hour token window and
|
|
745
|
+
* `unit: 6, number: 1` is the weekly one; `TIME_LIMIT` is the monthly MCP tool
|
|
746
|
+
* limit. Unknown limits are still surfaced, labeled by their raw type.
|
|
747
|
+
* @param {unknown} payload
|
|
748
|
+
*/
|
|
749
|
+
export function normalizeGlmUsage(payload) {
|
|
750
|
+
const limits = glmLimits(payload);
|
|
751
|
+
if (limits === null || limits.length === 0) {
|
|
752
|
+
throw new ProviderError("invalid-response", "GLM Code Plan: response has no limits array");
|
|
753
|
+
}
|
|
754
|
+
const windows = [];
|
|
755
|
+
const seen = new Map();
|
|
756
|
+
for (let index = 0; index < limits.length; index++) {
|
|
757
|
+
const raw = limits[index];
|
|
758
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
759
|
+
const percent = finiteNumber(raw.percentage);
|
|
760
|
+
if (percent === null) continue;
|
|
761
|
+
const type = typeof raw.type === "string" ? raw.type : "UNKNOWN";
|
|
762
|
+
let key;
|
|
763
|
+
let labelKey;
|
|
764
|
+
let hintKey = null;
|
|
765
|
+
let note;
|
|
766
|
+
if (type === "TOKENS_LIMIT") {
|
|
767
|
+
const unit = finiteNumber(raw.unit);
|
|
768
|
+
const number = finiteNumber(raw.number);
|
|
769
|
+
if (unit === 3 && number === 5) {
|
|
770
|
+
key = "five-hour";
|
|
771
|
+
} else if (unit === 6 && number === 1) {
|
|
772
|
+
key = "weekly";
|
|
773
|
+
} else {
|
|
774
|
+
key = "token";
|
|
775
|
+
note = unit !== null && number !== null ? `${unit}/${number}` : undefined;
|
|
776
|
+
}
|
|
777
|
+
} else if (type === "TIME_LIMIT") {
|
|
778
|
+
key = "mcp";
|
|
779
|
+
labelKey = "window.mcp";
|
|
780
|
+
hintKey = "window.mcp.hint";
|
|
781
|
+
} else {
|
|
782
|
+
key = "other";
|
|
783
|
+
labelKey = "window.other";
|
|
784
|
+
note = type;
|
|
785
|
+
}
|
|
786
|
+
const occurrence = seen.get(key) ?? 0;
|
|
787
|
+
seen.set(key, occurrence + 1);
|
|
788
|
+
if (occurrence > 0) key = `${key}-${occurrence + 1}`;
|
|
789
|
+
windows.push(makeWindow(key, percent, {
|
|
790
|
+
...(labelKey === undefined ? {} : { labelKey }),
|
|
791
|
+
hintKey,
|
|
792
|
+
status: percent >= 100 ? "exceeded" : "ok",
|
|
793
|
+
resetsAt: raw.nextResetTime,
|
|
794
|
+
note
|
|
795
|
+
}));
|
|
796
|
+
}
|
|
797
|
+
if (windows.length === 0) {
|
|
798
|
+
throw new ProviderError("invalid-response", "GLM Code Plan: limits contained no percentage values");
|
|
799
|
+
}
|
|
800
|
+
const order = { "five-hour": 0, weekly: 1, mcp: 2, token: 3, other: 4 };
|
|
801
|
+
windows.sort((a, b) => (order[/** @type {keyof typeof order} */ (a.key)] ?? 9) - (order[/** @type {keyof typeof order} */ (b.key)] ?? 9));
|
|
802
|
+
return windows;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Read GLM Code Plan quota.
|
|
807
|
+
*
|
|
808
|
+
* The API uses a raw `Authorization: <token>` header (no Bearer prefix) and
|
|
809
|
+
* answers HTTP 200 with `{ success: false, msg }` for account-level failures.
|
|
810
|
+
* In `auto` region mode both the international (`api.z.ai`) and China
|
|
811
|
+
* (`open.bigmodel.cn`) hosts are probed.
|
|
812
|
+
*
|
|
813
|
+
* @param {{
|
|
814
|
+
* config: { apiKeyEnv?: string, baseUrl?: string, region?: string },
|
|
815
|
+
* resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
|
|
816
|
+
* fetchImpl?: typeof fetch,
|
|
817
|
+
* }} input
|
|
818
|
+
*/
|
|
819
|
+
export async function readGlm(input) {
|
|
820
|
+
const { config, resolveCredential, fetchImpl = fetch } = input;
|
|
821
|
+
const refs = uniqueStrings([config.apiKeyEnv, ...GLM_KNOWN_REFS]).filter(isCredentialRefName);
|
|
822
|
+
const credential = await resolveCredential(refs);
|
|
823
|
+
if (credential === null) {
|
|
824
|
+
throw new ProviderError(
|
|
825
|
+
"no-credential",
|
|
826
|
+
`GLM Code Plan: no API key (set ${refs[0] ?? DEFAULT_GLM_API_KEY_ENV}, or ZAI_CODING_CN_API_KEY / ZHIPU_API_KEY)`,
|
|
827
|
+
{ account: { ref: refs[0] ?? DEFAULT_GLM_API_KEY_ENV, id: null, name: "" } }
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const bases = glmBases(config);
|
|
832
|
+
if (bases.length === 0) {
|
|
833
|
+
throw new ProviderError("no-endpoint", "GLM Code Plan: custom region needs glmBaseUrl", {
|
|
834
|
+
account: { ref: credential.ref, id: null, name: "" }
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
let lastError = null;
|
|
838
|
+
for (const base of bases) {
|
|
839
|
+
let payload;
|
|
840
|
+
try {
|
|
841
|
+
payload = await fetchJson(
|
|
842
|
+
fetchImpl,
|
|
843
|
+
glmQuotaUrl(base),
|
|
844
|
+
{
|
|
845
|
+
headers: {
|
|
846
|
+
// The GLM quota endpoint expects the raw token, not a Bearer scheme.
|
|
847
|
+
Authorization: String(credential.value).replace(/^Bearer\s+/i, ""),
|
|
848
|
+
"Content-Type": "application/json",
|
|
849
|
+
"Accept-Language": "en-US,en"
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
`GLM Code Plan (${base})`
|
|
853
|
+
);
|
|
854
|
+
} catch (error) {
|
|
855
|
+
lastError = error;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (payload?.success === false) {
|
|
859
|
+
const message = typeof payload.msg === "string" && payload.msg.length > 0
|
|
860
|
+
? payload.msg
|
|
861
|
+
: typeof payload.message === "string" && payload.message.length > 0 ? payload.message : "request unsuccessful";
|
|
862
|
+
lastError = new ProviderError("api-error", `GLM Code Plan (${base}): ${message}`);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
let windows;
|
|
866
|
+
try {
|
|
867
|
+
windows = normalizeGlmUsage(payload);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
lastError = error;
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
const data = payload?.data !== null && typeof payload?.data === "object" ? payload.data : {};
|
|
873
|
+
const planName = [data.planName, data.plan, data.packageName, data.level]
|
|
874
|
+
.find((value) => typeof value === "string" && value.length > 0) ?? null;
|
|
875
|
+
const accountName = [data.userName, data.user?.name, data.name]
|
|
876
|
+
.find((value) => typeof value === "string" && value.length > 0) ?? "";
|
|
877
|
+
return {
|
|
878
|
+
...providerInfo("glm"),
|
|
879
|
+
status: "ok",
|
|
880
|
+
account: { ref: credential.ref, id: null, name: accountName },
|
|
881
|
+
plan: planName === null ? { id: null, name: "GLM Code Plan", status: null, periodStart: null, periodEnd: null, totalCredits: null } : {
|
|
882
|
+
id: null,
|
|
883
|
+
name: planName,
|
|
884
|
+
status: null,
|
|
885
|
+
periodStart: null,
|
|
886
|
+
periodEnd: null,
|
|
887
|
+
totalCredits: null
|
|
888
|
+
},
|
|
889
|
+
source: `credential:${credential.ref}`,
|
|
890
|
+
requested: null,
|
|
891
|
+
fallback: null,
|
|
892
|
+
windows,
|
|
893
|
+
meta: { region: base }
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError ?? "no endpoint answered");
|
|
898
|
+
throw new ProviderError(lastError instanceof ProviderError ? lastError.code : "read-failed", message, {
|
|
899
|
+
account: { ref: credential.ref, id: null, name: "" }
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|