dsh-usage-state 0.3.0
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.en.md +126 -0
- package/README.md +158 -0
- package/cordis.patch.yml +7 -0
- package/docs/adapters.md +100 -0
- package/lib/client.js +1828 -0
- package/lib/index.js +1764 -0
- package/lib/typert.js +120 -0
- package/package.json +92 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/shared/config.ts
|
|
5
|
+
const DEFAULT_CONFIG = {
|
|
6
|
+
models: [],
|
|
7
|
+
order: [],
|
|
8
|
+
providers: {},
|
|
9
|
+
sources: {},
|
|
10
|
+
refresh: {
|
|
11
|
+
intervalMinutes: 5,
|
|
12
|
+
turnEndDelayMs: 2e3,
|
|
13
|
+
minIntervalSeconds: 60
|
|
14
|
+
},
|
|
15
|
+
display: {
|
|
16
|
+
thresholdWarnPercent: 80,
|
|
17
|
+
thresholdCriticalPercent: 95,
|
|
18
|
+
progressBar: true
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const MODES = [
|
|
22
|
+
"api",
|
|
23
|
+
"coding-plan",
|
|
24
|
+
"hidden"
|
|
25
|
+
];
|
|
26
|
+
const PROVIDER_MODES = [
|
|
27
|
+
"api",
|
|
28
|
+
"coding-plan",
|
|
29
|
+
"hidden",
|
|
30
|
+
"auto"
|
|
31
|
+
];
|
|
32
|
+
function asRecord$5(value) {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
34
|
+
}
|
|
35
|
+
function cleanString(value) {
|
|
36
|
+
return typeof value === "string" ? value.trim() : "";
|
|
37
|
+
}
|
|
38
|
+
function clampInt(value, minimum, maximum, fallback) {
|
|
39
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
40
|
+
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
|
41
|
+
}
|
|
42
|
+
function normalizeModels(value) {
|
|
43
|
+
if (!Array.isArray(value)) return [];
|
|
44
|
+
const models = [];
|
|
45
|
+
const seen = /* @__PURE__ */ new Set();
|
|
46
|
+
for (const raw of value) {
|
|
47
|
+
const entry = asRecord$5(raw);
|
|
48
|
+
if (entry === void 0) continue;
|
|
49
|
+
const provider = cleanString(entry.provider);
|
|
50
|
+
const model = cleanString(entry.model);
|
|
51
|
+
if (provider === "" || model === "") continue;
|
|
52
|
+
const identity = `${provider}\u0000${model}`;
|
|
53
|
+
if (seen.has(identity)) continue;
|
|
54
|
+
seen.add(identity);
|
|
55
|
+
const rawMode = cleanString(entry.mode);
|
|
56
|
+
const mode = MODES.includes(rawMode) ? rawMode : "hidden";
|
|
57
|
+
const sourceId = cleanString(entry.sourceId);
|
|
58
|
+
models.push({
|
|
59
|
+
provider,
|
|
60
|
+
model,
|
|
61
|
+
sourceId: sourceId === "" ? null : sourceId,
|
|
62
|
+
mode
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return models;
|
|
66
|
+
}
|
|
67
|
+
function normalizeSources(value) {
|
|
68
|
+
const root = asRecord$5(value);
|
|
69
|
+
if (root === void 0) return {};
|
|
70
|
+
const sources = {};
|
|
71
|
+
for (const [id, raw] of Object.entries(root)) {
|
|
72
|
+
const entry = asRecord$5(raw);
|
|
73
|
+
if (entry === void 0) continue;
|
|
74
|
+
const config = {};
|
|
75
|
+
const apiKeyRef = cleanString(entry.apiKeyRef);
|
|
76
|
+
if (apiKeyRef !== "") config.apiKeyRef = apiKeyRef;
|
|
77
|
+
const baseUrl = cleanString(entry.baseUrl);
|
|
78
|
+
if (baseUrl !== "") config.baseUrl = baseUrl;
|
|
79
|
+
if (config.apiKeyRef !== void 0 || config.baseUrl !== void 0) sources[id] = config;
|
|
80
|
+
}
|
|
81
|
+
return sources;
|
|
82
|
+
}
|
|
83
|
+
function normalizeRefresh(value) {
|
|
84
|
+
const root = asRecord$5(value) ?? {};
|
|
85
|
+
const defaults = DEFAULT_CONFIG.refresh;
|
|
86
|
+
return {
|
|
87
|
+
intervalMinutes: clampInt(root.intervalMinutes, 1, 1440, defaults.intervalMinutes),
|
|
88
|
+
turnEndDelayMs: clampInt(root.turnEndDelayMs, 0, 6e4, defaults.turnEndDelayMs),
|
|
89
|
+
minIntervalSeconds: clampInt(root.minIntervalSeconds, 0, 3600, defaults.minIntervalSeconds)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function normalizeDisplay(value) {
|
|
93
|
+
const root = asRecord$5(value) ?? {};
|
|
94
|
+
const defaults = DEFAULT_CONFIG.display;
|
|
95
|
+
const progressBar = typeof root.progressBar === "boolean" ? root.progressBar : defaults.progressBar;
|
|
96
|
+
const thresholdWarnPercent = clampInt(root.thresholdWarnPercent, 1, 100, defaults.thresholdWarnPercent);
|
|
97
|
+
const thresholdCriticalPercent = clampInt(root.thresholdCriticalPercent, 1, 100, defaults.thresholdCriticalPercent);
|
|
98
|
+
if (thresholdWarnPercent >= thresholdCriticalPercent) return {
|
|
99
|
+
...defaults,
|
|
100
|
+
progressBar
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
thresholdWarnPercent,
|
|
104
|
+
thresholdCriticalPercent,
|
|
105
|
+
progressBar
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function normalizeProviders(value) {
|
|
109
|
+
const root = asRecord$5(value);
|
|
110
|
+
if (root === void 0) return {};
|
|
111
|
+
const providers = {};
|
|
112
|
+
for (const [id, raw] of Object.entries(root)) {
|
|
113
|
+
const entry = asRecord$5(raw);
|
|
114
|
+
if (entry === void 0) continue;
|
|
115
|
+
const rawMode = cleanString(entry.mode);
|
|
116
|
+
const normalized = { mode: PROVIDER_MODES.includes(rawMode) ? rawMode : "auto" };
|
|
117
|
+
const sourceId = cleanString(entry.sourceId);
|
|
118
|
+
if (sourceId !== "") normalized.sourceId = sourceId;
|
|
119
|
+
const baseUrl = cleanString(entry.baseUrl);
|
|
120
|
+
if (baseUrl !== "") normalized.baseUrl = baseUrl;
|
|
121
|
+
const apiKeyRef = cleanString(entry.apiKeyRef);
|
|
122
|
+
if (apiKeyRef !== "") normalized.apiKeyRef = apiKeyRef;
|
|
123
|
+
providers[id] = normalized;
|
|
124
|
+
}
|
|
125
|
+
return providers;
|
|
126
|
+
}
|
|
127
|
+
/** Keep the stored order sane and append providers it does not mention. */
|
|
128
|
+
function normalizeOrder(value, providers) {
|
|
129
|
+
const order = [];
|
|
130
|
+
if (Array.isArray(value)) for (const raw of value) {
|
|
131
|
+
const id = cleanString(raw);
|
|
132
|
+
if (id === "" || order.includes(id)) continue;
|
|
133
|
+
order.push(id);
|
|
134
|
+
}
|
|
135
|
+
for (const id of Object.keys(providers)) if (!order.includes(id)) order.push(id);
|
|
136
|
+
return order;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Turn whatever the hand-editable settings document contains into a usable
|
|
140
|
+
* config. Deliberately never throws: the settings provider calls the schema
|
|
141
|
+
* synchronously at registration time, and a dirty section must not block the
|
|
142
|
+
* plugin from loading. Malformed pieces fall back to defaults instead.
|
|
143
|
+
*
|
|
144
|
+
* Legacy per-model entries are migrated into provider entries so an existing
|
|
145
|
+
* document keeps the choices its owner already made.
|
|
146
|
+
*/
|
|
147
|
+
function normalizeConfig(raw) {
|
|
148
|
+
const root = asRecord$5(raw) ?? {};
|
|
149
|
+
const models = normalizeModels(root.models);
|
|
150
|
+
const providers = normalizeProviders(root.providers);
|
|
151
|
+
for (const entry of models) {
|
|
152
|
+
if (providers[entry.provider] !== void 0) continue;
|
|
153
|
+
providers[entry.provider] = {
|
|
154
|
+
mode: entry.mode,
|
|
155
|
+
...entry.sourceId === null ? {} : { sourceId: entry.sourceId }
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
models,
|
|
160
|
+
order: normalizeOrder(root.order, providers),
|
|
161
|
+
providers,
|
|
162
|
+
sources: normalizeSources(root.sources),
|
|
163
|
+
refresh: normalizeRefresh(root.refresh),
|
|
164
|
+
display: normalizeDisplay(root.display)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const PROVIDER_HINTS = [
|
|
168
|
+
{
|
|
169
|
+
sourceId: "zai",
|
|
170
|
+
pattern: /(zai|zhipu|bigmodel|glm)/
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
sourceId: "kimi",
|
|
174
|
+
pattern: /(kimi|moonshot)/
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
sourceId: "sub2api",
|
|
178
|
+
pattern: /sub-?2-?api/
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
sourceId: "opencode",
|
|
182
|
+
pattern: /opencode/
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
sourceId: "deepseek",
|
|
186
|
+
pattern: /deepseek/
|
|
187
|
+
}
|
|
188
|
+
];
|
|
189
|
+
const HOST_HINTS = [
|
|
190
|
+
{
|
|
191
|
+
sourceId: "deepseek",
|
|
192
|
+
pattern: /(^|\.)api\.deepseek\.com$/
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
sourceId: "zai",
|
|
196
|
+
pattern: /(^|\.)(api\.z\.ai|open\.bigmodel\.cn|bigmodel\.cn)$/
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
sourceId: "kimi",
|
|
200
|
+
pattern: /(^|\.)(api\.kimi\.com|api\.moonshot\.cn|moonshot\.cn)$/
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
sourceId: "opencode",
|
|
204
|
+
pattern: /(^|\.)opencode\.ai$/
|
|
205
|
+
}
|
|
206
|
+
];
|
|
207
|
+
/**
|
|
208
|
+
* Best guess at which data source backs a DSH provider. Provider id first (cheap
|
|
209
|
+
* and usually right), then the endpoint host. Because any unrecognised endpoint is
|
|
210
|
+
* most likely a self-hosted gateway, that is the last resort rather than `undefined`.
|
|
211
|
+
*/
|
|
212
|
+
function suggestSourceId(providerId, baseUrl) {
|
|
213
|
+
const normalized = providerId.trim().toLowerCase();
|
|
214
|
+
for (const hint of PROVIDER_HINTS) if (hint.pattern.test(normalized)) return hint.sourceId;
|
|
215
|
+
if (baseUrl === void 0) return void 0;
|
|
216
|
+
let host;
|
|
217
|
+
try {
|
|
218
|
+
host = new URL(baseUrl.trim()).host.toLowerCase();
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (const hint of HOST_HINTS) if (hint.pattern.test(host)) return hint.sourceId;
|
|
223
|
+
return "sub2api";
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/host/sources/normalize.ts
|
|
227
|
+
/** Shared normalization helpers for provider payloads. */
|
|
228
|
+
/** Numbers and numeric strings to a finite number; anything else is absent. */
|
|
229
|
+
function toFiniteNumber(value) {
|
|
230
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
231
|
+
if (typeof value === "string") {
|
|
232
|
+
const trimmed = value.trim();
|
|
233
|
+
if (trimmed === "") return void 0;
|
|
234
|
+
const parsed = Number(trimmed);
|
|
235
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Clamp to 0..100 and round to one decimal. */
|
|
239
|
+
function clampPercent(value) {
|
|
240
|
+
if (!Number.isFinite(value)) return 0;
|
|
241
|
+
return Math.min(100, Math.max(0, Math.round(value * 10) / 10));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Percentages arrive in two conventions: 0..1 fractions and 0..100 percentages.
|
|
245
|
+
* Values `<= 1` are read as fractions (so a literal "1" means 100%), anything
|
|
246
|
+
* larger is read as a percentage. Invalid or negative input yields `null`.
|
|
247
|
+
*/
|
|
248
|
+
function normalizePercent(value) {
|
|
249
|
+
const parsed = toFiniteNumber(value);
|
|
250
|
+
if (parsed === void 0 || parsed < 0) return null;
|
|
251
|
+
return clampPercent(parsed <= 1 ? parsed * 100 : parsed);
|
|
252
|
+
}
|
|
253
|
+
function fromNumericInstant(value) {
|
|
254
|
+
if (!Number.isFinite(value) || value <= 0) return void 0;
|
|
255
|
+
return value > 0xe8d4a51000 ? Math.round(value) : Math.round(value * 1e3);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Reset instants arrive as unix seconds, unix milliseconds, numeric strings or
|
|
259
|
+
* ISO timestamps; all of them become epoch milliseconds. Missing or nonsense
|
|
260
|
+
* values become `undefined` rather than a bogus date.
|
|
261
|
+
*/
|
|
262
|
+
function normalizeResetAt(value) {
|
|
263
|
+
if (typeof value === "string") {
|
|
264
|
+
const trimmed = value.trim();
|
|
265
|
+
if (trimmed === "") return void 0;
|
|
266
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
267
|
+
const numeric = toFiniteNumber(trimmed);
|
|
268
|
+
return numeric === void 0 ? void 0 : fromNumericInstant(numeric);
|
|
269
|
+
}
|
|
270
|
+
const parsed = Date.parse(trimmed);
|
|
271
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
272
|
+
}
|
|
273
|
+
const numeric = toFiniteNumber(value);
|
|
274
|
+
return numeric === void 0 ? void 0 : fromNumericInstant(numeric);
|
|
275
|
+
}
|
|
276
|
+
/** Trim whitespace, trailing slashes and a trailing `/vN` API version segment. */
|
|
277
|
+
function normalizeBaseUrl(value) {
|
|
278
|
+
if (typeof value !== "string") return void 0;
|
|
279
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
280
|
+
if (trimmed === "") return void 0;
|
|
281
|
+
const withoutVersion = trimmed.replace(/\/v\d+$/i, "");
|
|
282
|
+
return withoutVersion === "" ? void 0 : withoutVersion;
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/host/sources/types.ts
|
|
286
|
+
var SourceError = class extends Error {
|
|
287
|
+
kind;
|
|
288
|
+
constructor(kind, message) {
|
|
289
|
+
super(message);
|
|
290
|
+
this.name = "SourceError";
|
|
291
|
+
this.kind = kind;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/host/sources/deepseek.ts
|
|
296
|
+
const DEFAULT_BASE_URL$1 = "https://api.deepseek.com";
|
|
297
|
+
const BALANCE_PATH = "/user/balance";
|
|
298
|
+
/**
|
|
299
|
+
* DeepSeek returns one entry per currency and the order is not stable, so a
|
|
300
|
+
* fixed "first entry" read makes the displayed balance flip between the real
|
|
301
|
+
* value and zero. Rule: prefer an entry with a positive balance, prefer CNY
|
|
302
|
+
* among equals (the open platform's primary currency), and fall back to CNY or
|
|
303
|
+
* the first entry so the result never depends on response ordering.
|
|
304
|
+
*/
|
|
305
|
+
function pickBalanceInfo(infos) {
|
|
306
|
+
if (!Array.isArray(infos)) return void 0;
|
|
307
|
+
const entries = [];
|
|
308
|
+
for (const raw of infos) {
|
|
309
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
310
|
+
const entry = raw;
|
|
311
|
+
const granted = toFiniteNumber(entry.granted_balance);
|
|
312
|
+
const toppedUp = toFiniteNumber(entry.topped_up_balance);
|
|
313
|
+
entries.push({
|
|
314
|
+
amount: toFiniteNumber(entry.total_balance) ?? 0,
|
|
315
|
+
currency: typeof entry.currency === "string" ? entry.currency : "",
|
|
316
|
+
...granted === void 0 ? {} : { granted },
|
|
317
|
+
...toppedUp === void 0 ? {} : { toppedUp }
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (entries.length === 0) return void 0;
|
|
321
|
+
const cnyOf = (list) => list.find((entry) => entry.currency.toUpperCase() === "CNY");
|
|
322
|
+
const funded = entries.filter((entry) => entry.amount > 0);
|
|
323
|
+
return cnyOf(funded) ?? funded[0] ?? cnyOf(entries) ?? entries[0];
|
|
324
|
+
}
|
|
325
|
+
/** DeepSeek official API: balance only — the platform has no coding plan. */
|
|
326
|
+
const deepseek = {
|
|
327
|
+
id: "deepseek",
|
|
328
|
+
displayName: "DeepSeek",
|
|
329
|
+
modes: ["api"],
|
|
330
|
+
credentialRefs: () => ["DEEPSEEK_API_KEY"],
|
|
331
|
+
defaultBaseUrl: () => DEFAULT_BASE_URL$1,
|
|
332
|
+
request(input) {
|
|
333
|
+
return {
|
|
334
|
+
url: `${normalizeBaseUrl(input.baseUrl) ?? DEFAULT_BASE_URL$1}${BALANCE_PATH}`,
|
|
335
|
+
headers: {
|
|
336
|
+
authorization: `Bearer ${input.apiKey}`,
|
|
337
|
+
accept: "application/json"
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
parse(payload, _mode) {
|
|
342
|
+
const root = payload !== null && typeof payload === "object" ? payload : void 0;
|
|
343
|
+
const picked = root === void 0 ? void 0 : pickBalanceInfo(root.balance_infos);
|
|
344
|
+
if (picked === void 0) throw new SourceError("parse", "DeepSeek balance response contains no usable balance_infos");
|
|
345
|
+
return {
|
|
346
|
+
balances: [{
|
|
347
|
+
amount: picked.amount,
|
|
348
|
+
currency: picked.currency,
|
|
349
|
+
...picked.granted === void 0 ? {} : { granted: picked.granted },
|
|
350
|
+
...picked.toppedUp === void 0 ? {} : { toppedUp: picked.toppedUp }
|
|
351
|
+
}],
|
|
352
|
+
windows: []
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region src/host/sources/kimi.ts
|
|
358
|
+
const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
|
|
359
|
+
const KIMI_CODE_BASE_URL = "https://api.kimi.com";
|
|
360
|
+
/** The Kimi Code quota endpoint rejects requests without the CLI user agent. */
|
|
361
|
+
const KIMI_CLI_USER_AGENT = "KimiCLI/1.6";
|
|
362
|
+
const WINDOW_RANK$2 = {
|
|
363
|
+
"5h": 0,
|
|
364
|
+
"1d": 1,
|
|
365
|
+
"7d": 2
|
|
366
|
+
};
|
|
367
|
+
function asRecord$4(value) {
|
|
368
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
369
|
+
}
|
|
370
|
+
function firstFinite(candidates) {
|
|
371
|
+
for (const candidate of candidates) {
|
|
372
|
+
const parsed = toFiniteNumber(candidate);
|
|
373
|
+
if (parsed !== void 0) return parsed;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Moonshot's balance is documented in cents by some sources and in yuan by others,
|
|
378
|
+
* and there is no field that disambiguates the two. This mirrors the heuristic used
|
|
379
|
+
* by the plugin this project replaces: amounts of 100 or more are read as cents.
|
|
380
|
+
* Verified against a real key before trusting the absolute value.
|
|
381
|
+
*/
|
|
382
|
+
function moonshotBalanceToYuan(value) {
|
|
383
|
+
const yuan = value >= 100 ? value / 100 : value;
|
|
384
|
+
return Math.round(yuan * 100) / 100;
|
|
385
|
+
}
|
|
386
|
+
/** Used percentage from an explicit used/limit pair, or by inverting remaining. */
|
|
387
|
+
function percentFrom(record) {
|
|
388
|
+
const limit = toFiniteNumber(record.limit);
|
|
389
|
+
if (limit === void 0 || limit <= 0) return null;
|
|
390
|
+
const used = toFiniteNumber(record.used);
|
|
391
|
+
if (used !== void 0) return clampPercent(used / limit * 100);
|
|
392
|
+
const remaining = toFiniteNumber(record.remaining);
|
|
393
|
+
if (remaining !== void 0) return clampPercent((limit - remaining) / limit * 100);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
/** `{ duration: 5, timeUnit: 'hour' }` -> `5h`, and weekly variants -> `7d`. */
|
|
397
|
+
function windowIdFrom(window) {
|
|
398
|
+
if (window === void 0) return void 0;
|
|
399
|
+
const unit = typeof window.timeUnit === "string" ? window.timeUnit.trim().toLowerCase() : "";
|
|
400
|
+
const duration = toFiniteNumber(window.duration);
|
|
401
|
+
if (unit === "" || duration === void 0 || duration <= 0) return void 0;
|
|
402
|
+
if (unit.startsWith("hour")) return `${duration}h`;
|
|
403
|
+
if (unit.startsWith("day")) return duration === 7 ? "7d" : `${duration}d`;
|
|
404
|
+
if (unit.startsWith("week")) return duration === 1 ? "7d" : `${duration * 7}d`;
|
|
405
|
+
if (unit.startsWith("minute")) return `${duration}m`;
|
|
406
|
+
if (unit.startsWith("month")) return "30d";
|
|
407
|
+
return `${duration}${unit.slice(0, 4)}`;
|
|
408
|
+
}
|
|
409
|
+
function sortWindows$2(windows) {
|
|
410
|
+
return [...windows].sort((a, b) => (WINDOW_RANK$2[a.id] ?? 99) - (WINDOW_RANK$2[b.id] ?? 99));
|
|
411
|
+
}
|
|
412
|
+
/** `GET /v1/users/me/balance` — pay-as-you-go wallet, CNY. */
|
|
413
|
+
function parseMoonshotBalance(payload) {
|
|
414
|
+
const root = asRecord$4(payload);
|
|
415
|
+
const nested = root === void 0 ? void 0 : asRecord$4(root.data);
|
|
416
|
+
const value = firstFinite([
|
|
417
|
+
root?.available_balance,
|
|
418
|
+
root?.balance,
|
|
419
|
+
root?.cash_balance,
|
|
420
|
+
nested?.available_balance,
|
|
421
|
+
nested?.balance
|
|
422
|
+
]);
|
|
423
|
+
if (value === void 0 || value < 0) throw new SourceError("parse", "Moonshot balance response contains no usable balance field");
|
|
424
|
+
return {
|
|
425
|
+
balances: [{
|
|
426
|
+
amount: moonshotBalanceToYuan(value),
|
|
427
|
+
currency: "CNY"
|
|
428
|
+
}],
|
|
429
|
+
windows: []
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* `GET /coding/v1/usages` — Kimi Code subscription. The top-level `usage` is the
|
|
434
|
+
* weekly quota; `limits[]` carries the rolling windows (5h among them). Neither is
|
|
435
|
+
* officially documented, so every field is optional here.
|
|
436
|
+
*/
|
|
437
|
+
function parseKimiCodeUsage(payload) {
|
|
438
|
+
const root = asRecord$4(payload);
|
|
439
|
+
if (root === void 0) throw new SourceError("parse", "Kimi Code usage response is not a JSON object");
|
|
440
|
+
const found = /* @__PURE__ */ new Map();
|
|
441
|
+
const usage = asRecord$4(root.usage);
|
|
442
|
+
if (usage !== void 0) {
|
|
443
|
+
const usedPercent = percentFrom(usage);
|
|
444
|
+
if (usedPercent !== null) {
|
|
445
|
+
const resetsAt = normalizeResetAt(usage.resetTime ?? usage.reset_at ?? usage.resetsAt);
|
|
446
|
+
found.set("7d", resetsAt === void 0 ? {
|
|
447
|
+
id: "7d",
|
|
448
|
+
usedPercent
|
|
449
|
+
} : {
|
|
450
|
+
id: "7d",
|
|
451
|
+
usedPercent,
|
|
452
|
+
resetsAt
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const limits = Array.isArray(root.limits) ? root.limits : [];
|
|
457
|
+
for (const raw of limits) {
|
|
458
|
+
const row = asRecord$4(raw);
|
|
459
|
+
const detail = row === void 0 ? void 0 : asRecord$4(row.detail);
|
|
460
|
+
if (detail === void 0) continue;
|
|
461
|
+
const usedPercent = percentFrom(detail);
|
|
462
|
+
if (usedPercent === null) continue;
|
|
463
|
+
const id = windowIdFrom(asRecord$4(row?.window));
|
|
464
|
+
if (id === void 0 || found.has(id)) continue;
|
|
465
|
+
const resetsAt = normalizeResetAt(detail.resetTime ?? detail.reset_at ?? detail.resetsAt);
|
|
466
|
+
found.set(id, resetsAt === void 0 ? {
|
|
467
|
+
id,
|
|
468
|
+
usedPercent
|
|
469
|
+
} : {
|
|
470
|
+
id,
|
|
471
|
+
usedPercent,
|
|
472
|
+
resetsAt
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (found.size === 0) throw new SourceError("parse", "Kimi Code usage response contains no usable quota window");
|
|
476
|
+
return {
|
|
477
|
+
balances: [],
|
|
478
|
+
windows: sortWindows$2(found.values())
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Kimi / Moonshot. One vendor, two very different readings: the pay-as-you-go
|
|
483
|
+
* wallet in API mode (`api.moonshot.cn`) and the Kimi Code subscription windows in
|
|
484
|
+
* coding-plan mode (`api.kimi.com`).
|
|
485
|
+
*/
|
|
486
|
+
const kimi = {
|
|
487
|
+
id: "kimi",
|
|
488
|
+
displayName: "Kimi / Moonshot",
|
|
489
|
+
modes: ["api", "coding-plan"],
|
|
490
|
+
credentialRefs(mode) {
|
|
491
|
+
return mode === "coding-plan" ? [
|
|
492
|
+
"KIMI_CODING_API_KEY",
|
|
493
|
+
"KIMI_API_KEY",
|
|
494
|
+
"MOONSHOT_API_KEY"
|
|
495
|
+
] : ["MOONSHOT_API_KEY", "KIMI_API_KEY"];
|
|
496
|
+
},
|
|
497
|
+
defaultBaseUrl(mode) {
|
|
498
|
+
return mode === "coding-plan" ? KIMI_CODE_BASE_URL : MOONSHOT_BASE_URL;
|
|
499
|
+
},
|
|
500
|
+
request(input) {
|
|
501
|
+
const codingPlan = input.mode === "coding-plan";
|
|
502
|
+
const base = normalizeBaseUrl(input.baseUrl) ?? (codingPlan ? KIMI_CODE_BASE_URL : MOONSHOT_BASE_URL);
|
|
503
|
+
return {
|
|
504
|
+
url: codingPlan ? `${base}/coding/v1/usages` : `${base}/v1/users/me/balance`,
|
|
505
|
+
headers: {
|
|
506
|
+
authorization: `Bearer ${input.apiKey}`,
|
|
507
|
+
accept: "application/json",
|
|
508
|
+
...codingPlan ? { "user-agent": KIMI_CLI_USER_AGENT } : {}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
},
|
|
512
|
+
parse(payload, mode) {
|
|
513
|
+
return mode === "coding-plan" ? parseKimiCodeUsage(payload) : parseMoonshotBalance(payload);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
//#endregion
|
|
517
|
+
//#region src/host/sources/opencode.ts
|
|
518
|
+
const DEFAULT_BASE_URL = "https://opencode.ai";
|
|
519
|
+
const QUOTA_PATH$1 = "/zen/go/v1/usage";
|
|
520
|
+
/**
|
|
521
|
+
* Cloudflare fronts opencode.ai and answers a non-browser agent with error 1010
|
|
522
|
+
* (a flat 403), so the quota request has to present a real Chrome user agent.
|
|
523
|
+
*/
|
|
524
|
+
const BROWSER_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
525
|
+
/**
|
|
526
|
+
* The quota endpoint lives under `/zen/go/v1`. A provider profile declares its
|
|
527
|
+
* `baseURL` as the *API* base (`https://opencode.ai/zen/go/v1`), the settings page can
|
|
528
|
+
* pin that same string, and a user can paste the documented URL in full — so the base
|
|
529
|
+
* can arrive already carrying any suffix of our own path. `normalizeBaseUrl` has
|
|
530
|
+
* already dropped a trailing `/vN`; drop whatever of `/zen/go`, `/vN` and `/usage`
|
|
531
|
+
* remains, or the path would be appended to itself.
|
|
532
|
+
*/
|
|
533
|
+
function endpointOrigin(value) {
|
|
534
|
+
return (normalizeBaseUrl(value) ?? DEFAULT_BASE_URL).replace(/\/zen\/go(?:\/v\d+)?(?:\/usage)?$/i, "");
|
|
535
|
+
}
|
|
536
|
+
function asRecord$3(value) {
|
|
537
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The Go plan names its windows by cadence. `rolling` is the 5-hour window,
|
|
541
|
+
* `weekly` the 7-day one, and `monthly` the 30-day one — the same canonical ids
|
|
542
|
+
* the status line already orders and localises.
|
|
543
|
+
*/
|
|
544
|
+
const WINDOW_IDS = [
|
|
545
|
+
["rolling", "5h"],
|
|
546
|
+
["weekly", "7d"],
|
|
547
|
+
["monthly", "30d"]
|
|
548
|
+
];
|
|
549
|
+
function parseUsage(payload) {
|
|
550
|
+
const root = asRecord$3(payload);
|
|
551
|
+
if (root === void 0) throw new SourceError("parse", "OpenCode usage response is not a JSON object");
|
|
552
|
+
const usage = asRecord$3(root.usage) ?? asRecord$3(asRecord$3(root.data)?.usage);
|
|
553
|
+
if (usage === void 0) throw new SourceError("parse", "OpenCode usage response carries no usage windows");
|
|
554
|
+
const windows = [];
|
|
555
|
+
for (const [key, id] of WINDOW_IDS) {
|
|
556
|
+
const entry = asRecord$3(usage[key]);
|
|
557
|
+
if (entry === void 0) continue;
|
|
558
|
+
const percent = toFiniteNumber(entry.percent);
|
|
559
|
+
if (percent === void 0) continue;
|
|
560
|
+
const usedPercent = clampPercent(percent);
|
|
561
|
+
const resetsAt = normalizeResetAt(entry.resetsAt);
|
|
562
|
+
windows.push(resetsAt === void 0 ? {
|
|
563
|
+
id,
|
|
564
|
+
usedPercent
|
|
565
|
+
} : {
|
|
566
|
+
id,
|
|
567
|
+
usedPercent,
|
|
568
|
+
resetsAt
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (windows.length === 0) throw new SourceError("parse", "OpenCode usage response contains no usable quota window");
|
|
572
|
+
return {
|
|
573
|
+
balances: [],
|
|
574
|
+
windows
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const opencode = {
|
|
578
|
+
id: "opencode",
|
|
579
|
+
displayName: "OpenCode Zen Go",
|
|
580
|
+
modes: ["coding-plan"],
|
|
581
|
+
credentialRefs: () => ["OPENCODE_GO_API_KEY", "OPENCODE_API_KEY"],
|
|
582
|
+
defaultBaseUrl: () => DEFAULT_BASE_URL,
|
|
583
|
+
request(input) {
|
|
584
|
+
return {
|
|
585
|
+
url: `${endpointOrigin(input.baseUrl)}${QUOTA_PATH$1}`,
|
|
586
|
+
headers: {
|
|
587
|
+
authorization: `Bearer ${input.apiKey}`,
|
|
588
|
+
accept: "application/json",
|
|
589
|
+
"user-agent": BROWSER_USER_AGENT
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
},
|
|
593
|
+
parse(payload, _mode) {
|
|
594
|
+
return parseUsage(payload);
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/host/sources/sub2api.ts
|
|
599
|
+
const USAGE_PATH = "/v1/usage";
|
|
600
|
+
const DEFAULT_CURRENCY = "USD";
|
|
601
|
+
const WINDOW_RANK$1 = {
|
|
602
|
+
"5h": 0,
|
|
603
|
+
"1d": 1,
|
|
604
|
+
"7d": 2,
|
|
605
|
+
"30d": 3
|
|
606
|
+
};
|
|
607
|
+
/** Subscription window name -> our canonical id. */
|
|
608
|
+
const SUBSCRIPTION_WINDOWS = [
|
|
609
|
+
{
|
|
610
|
+
prefix: "daily",
|
|
611
|
+
id: "1d"
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
prefix: "weekly",
|
|
615
|
+
id: "7d"
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
prefix: "monthly",
|
|
619
|
+
id: "30d"
|
|
620
|
+
}
|
|
621
|
+
];
|
|
622
|
+
function asRecord$2(value) {
|
|
623
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
624
|
+
}
|
|
625
|
+
function currencyOf(value) {
|
|
626
|
+
if (typeof value !== "string") return DEFAULT_CURRENCY;
|
|
627
|
+
const trimmed = value.trim().toUpperCase();
|
|
628
|
+
return trimmed === "" ? DEFAULT_CURRENCY : trimmed;
|
|
629
|
+
}
|
|
630
|
+
/** `window: "5h" | "1d" | "7d"` as sent by Sub2API; unknown names are kept as-is. */
|
|
631
|
+
function rateWindowId(raw) {
|
|
632
|
+
if (typeof raw !== "string") return void 0;
|
|
633
|
+
const key = raw.trim().toLowerCase();
|
|
634
|
+
if (key === "") return void 0;
|
|
635
|
+
if (key === "5h") return "5h";
|
|
636
|
+
if (key === "1d" || key === "24h") return "1d";
|
|
637
|
+
if (key === "7d" || key === "168h") return "7d";
|
|
638
|
+
if (key === "30d" || key === "1mo") return "30d";
|
|
639
|
+
return /^[a-z0-9.]+$/.test(key) ? key : void 0;
|
|
640
|
+
}
|
|
641
|
+
function sortWindows$1(windows) {
|
|
642
|
+
return [...windows].sort((a, b) => (WINDOW_RANK$1[a.id] ?? 99) - (WINDOW_RANK$1[b.id] ?? 99));
|
|
643
|
+
}
|
|
644
|
+
function percentFromPair(used, limit) {
|
|
645
|
+
const cap = toFiniteNumber(limit);
|
|
646
|
+
const spent = toFiniteNumber(used);
|
|
647
|
+
if (cap === void 0 || cap <= 0 || spent === void 0) return null;
|
|
648
|
+
return clampPercent(spent / cap * 100);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Sub2API is a self-hosted gateway that redistributes subscription quota as its own
|
|
652
|
+
* `sk-` keys. `GET /v1/usage` is the only endpoint an API key can read, it is
|
|
653
|
+
* undocumented and its field names have drifted between frontend and backend, so
|
|
654
|
+
* every field here is optional and unknown shapes degrade instead of throwing.
|
|
655
|
+
*/
|
|
656
|
+
const sub2api = {
|
|
657
|
+
id: "sub2api",
|
|
658
|
+
displayName: "Sub2API",
|
|
659
|
+
modes: ["api", "coding-plan"],
|
|
660
|
+
requiresBaseUrl: true,
|
|
661
|
+
credentialRefs: () => ["SUB2API_API_KEY"],
|
|
662
|
+
/** Self-hosted: there is no sensible default endpoint. */
|
|
663
|
+
defaultBaseUrl: () => void 0,
|
|
664
|
+
request(input) {
|
|
665
|
+
const base = normalizeBaseUrl(input.baseUrl);
|
|
666
|
+
if (base === void 0) throw new SourceError("config", "Sub2API requires the base URL of your own instance");
|
|
667
|
+
return {
|
|
668
|
+
url: `${base}${USAGE_PATH}`,
|
|
669
|
+
headers: {
|
|
670
|
+
authorization: `Bearer ${input.apiKey}`,
|
|
671
|
+
accept: "application/json"
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
parse(payload, _mode) {
|
|
676
|
+
const root = asRecord$2(payload);
|
|
677
|
+
if (root === void 0) throw new SourceError("parse", "Sub2API usage response is not a JSON object");
|
|
678
|
+
const balances = [];
|
|
679
|
+
const windows = /* @__PURE__ */ new Map();
|
|
680
|
+
const quota = asRecord$2(root.quota);
|
|
681
|
+
const quotaRemaining = quota === void 0 ? void 0 : toFiniteNumber(quota.remaining);
|
|
682
|
+
const quotaLimit = quota === void 0 ? void 0 : toFiniteNumber(quota.limit);
|
|
683
|
+
if (quotaRemaining !== void 0 && quotaRemaining >= 0 && quotaLimit !== void 0 && quotaLimit > 0) balances.push({
|
|
684
|
+
amount: quotaRemaining,
|
|
685
|
+
currency: currencyOf(quota?.unit ?? root.unit)
|
|
686
|
+
});
|
|
687
|
+
else {
|
|
688
|
+
const wallet = toFiniteNumber(root.balance);
|
|
689
|
+
if (wallet !== void 0 && wallet >= 0) balances.push({
|
|
690
|
+
amount: wallet,
|
|
691
|
+
currency: currencyOf(root.unit)
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const rateLimits = Array.isArray(root.rate_limits) ? root.rate_limits : [];
|
|
695
|
+
for (const raw of rateLimits) {
|
|
696
|
+
const entry = asRecord$2(raw);
|
|
697
|
+
if (entry === void 0) continue;
|
|
698
|
+
const id = rateWindowId(entry.window);
|
|
699
|
+
if (id === void 0 || windows.has(id)) continue;
|
|
700
|
+
const usedPercent = percentFromPair(entry.used, entry.limit);
|
|
701
|
+
if (usedPercent === null) continue;
|
|
702
|
+
const resetsAt = normalizeResetAt(entry.reset_at ?? entry.resetAt);
|
|
703
|
+
windows.set(id, resetsAt === void 0 ? {
|
|
704
|
+
id,
|
|
705
|
+
usedPercent
|
|
706
|
+
} : {
|
|
707
|
+
id,
|
|
708
|
+
usedPercent,
|
|
709
|
+
resetsAt
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const subscription = asRecord$2(root.subscription);
|
|
713
|
+
if (subscription !== void 0) for (const { prefix, id } of SUBSCRIPTION_WINDOWS) {
|
|
714
|
+
if (windows.has(id)) continue;
|
|
715
|
+
const usedPercent = percentFromPair(subscription[`${prefix}_usage_usd`], subscription[`${prefix}_limit_usd`]);
|
|
716
|
+
if (usedPercent === null) continue;
|
|
717
|
+
windows.set(id, {
|
|
718
|
+
id,
|
|
719
|
+
usedPercent
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
const sorted = sortWindows$1(windows.values());
|
|
723
|
+
if (balances.length === 0 && sorted.length === 0) throw new SourceError("parse", "Sub2API usage response carries neither balance nor quota window");
|
|
724
|
+
return {
|
|
725
|
+
balances,
|
|
726
|
+
windows: sorted
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/host/sources/zai.ts
|
|
732
|
+
/**
|
|
733
|
+
* GLM coding plans are regional and the key only works on its own region's host:
|
|
734
|
+
* a China key answers `身份验证失败` on the global host and vice versa, with HTTP
|
|
735
|
+
* 200 rather than a redirect. China is the primary because that is where the
|
|
736
|
+
* coding plan is sold; the global host is the mirror.
|
|
737
|
+
*/
|
|
738
|
+
const CN_BASE_URL = "https://open.bigmodel.cn";
|
|
739
|
+
const GLOBAL_BASE_URL = "https://api.z.ai";
|
|
740
|
+
const QUOTA_PATH = "/api/monitor/usage/quota/limit";
|
|
741
|
+
/** Display order for the windows we know; anything else keeps its insertion order after these. */
|
|
742
|
+
const WINDOW_RANK = {
|
|
743
|
+
"5h": 0,
|
|
744
|
+
"1d": 1,
|
|
745
|
+
"7d": 2
|
|
746
|
+
};
|
|
747
|
+
/** Provider window names -> our canonical ids. Unknown names are kept verbatim. */
|
|
748
|
+
function canonicalWindowId(raw) {
|
|
749
|
+
const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
750
|
+
if (/^(five_?hour|fivehour|rolling|5h)$/.test(key)) return "5h";
|
|
751
|
+
if (/^(weekly|week|seven_?day|sevenday|7d)$/.test(key)) return "7d";
|
|
752
|
+
if (/^(daily|day|1d)$/.test(key)) return "1d";
|
|
753
|
+
if (/^month(ly)?$/.test(key)) return "30d";
|
|
754
|
+
return key;
|
|
755
|
+
}
|
|
756
|
+
function asRecord$1(value) {
|
|
757
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
758
|
+
}
|
|
759
|
+
/** Percentage of one `limits[]` entry: explicit `percentage` first, else currentValue/usage. */
|
|
760
|
+
function percentOfLimit(limit) {
|
|
761
|
+
const explicit = toFiniteNumber(limit.percentage);
|
|
762
|
+
if (explicit !== void 0) return clampPercent(explicit);
|
|
763
|
+
const usage = toFiniteNumber(limit.usage);
|
|
764
|
+
const current = toFiniteNumber(limit.currentValue);
|
|
765
|
+
if (usage !== void 0 && usage > 0 && current !== void 0) return clampPercent(current / usage * 100);
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
/** The 2026-08 monitor endpoint: `data.limits[]`, mapped by unit (3 = hours, 6 = weeks). */
|
|
769
|
+
function parseLimits(data) {
|
|
770
|
+
const body = asRecord$1(data.data);
|
|
771
|
+
const limits = body === void 0 ? void 0 : body.limits;
|
|
772
|
+
if (!Array.isArray(limits)) return void 0;
|
|
773
|
+
const found = /* @__PURE__ */ new Map();
|
|
774
|
+
const unassigned = [];
|
|
775
|
+
for (const raw of limits) {
|
|
776
|
+
const limit = asRecord$1(raw);
|
|
777
|
+
if (limit === void 0) continue;
|
|
778
|
+
if (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT") continue;
|
|
779
|
+
const usedPercent = percentOfLimit(limit);
|
|
780
|
+
if (usedPercent === null) continue;
|
|
781
|
+
const resetsAt = normalizeResetAt(limit.nextResetTime);
|
|
782
|
+
const window = resetsAt === void 0 ? {
|
|
783
|
+
id: "",
|
|
784
|
+
usedPercent
|
|
785
|
+
} : {
|
|
786
|
+
id: "",
|
|
787
|
+
usedPercent,
|
|
788
|
+
resetsAt
|
|
789
|
+
};
|
|
790
|
+
const unit = toFiniteNumber(limit.unit);
|
|
791
|
+
if (unit === 3) {
|
|
792
|
+
if (!found.has("5h")) found.set("5h", {
|
|
793
|
+
...window,
|
|
794
|
+
id: "5h"
|
|
795
|
+
});
|
|
796
|
+
} else if (unit === 6) {
|
|
797
|
+
if (!found.has("7d")) found.set("7d", {
|
|
798
|
+
...window,
|
|
799
|
+
id: "7d"
|
|
800
|
+
});
|
|
801
|
+
} else if (unit === void 0) unassigned.push({
|
|
802
|
+
usedPercent,
|
|
803
|
+
...resetsAt === void 0 ? {} : { resetsAt },
|
|
804
|
+
resetMs: resetsAt ?? 0
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
unassigned.sort((a, b) => a.resetMs - b.resetMs);
|
|
808
|
+
for (const item of unassigned) {
|
|
809
|
+
const id = found.has("5h") ? found.has("7d") ? void 0 : "7d" : "5h";
|
|
810
|
+
if (id === void 0) break;
|
|
811
|
+
found.set(id, {
|
|
812
|
+
id,
|
|
813
|
+
usedPercent: item.usedPercent,
|
|
814
|
+
...item.resetsAt === void 0 ? {} : { resetsAt: item.resetsAt }
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return found.size > 0 ? sortWindows(found.values()) : void 0;
|
|
818
|
+
}
|
|
819
|
+
/** The legacy billing endpoint: `plans[]`, where the reset span decides 5h vs weekly. */
|
|
820
|
+
function parsePlans(data) {
|
|
821
|
+
if (!Array.isArray(data.plans)) return void 0;
|
|
822
|
+
const found = /* @__PURE__ */ new Map();
|
|
823
|
+
for (const raw of data.plans) {
|
|
824
|
+
const plan = asRecord$1(raw);
|
|
825
|
+
if (plan === void 0) continue;
|
|
826
|
+
const total = toFiniteNumber(plan.total_units);
|
|
827
|
+
const used = toFiniteNumber(plan.used_units);
|
|
828
|
+
const usedPercent = total !== void 0 && total > 0 && used !== void 0 ? clampPercent(used / total * 100) : normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage);
|
|
829
|
+
if (usedPercent === null) continue;
|
|
830
|
+
const resetsAt = normalizeResetAt(plan.period_end);
|
|
831
|
+
const spanMs = resetsAt === void 0 ? NaN : resetsAt - Date.now();
|
|
832
|
+
const id = Number.isFinite(spanMs) && spanMs > 864e5 ? "7d" : "5h";
|
|
833
|
+
found.set(id, resetsAt === void 0 ? {
|
|
834
|
+
id,
|
|
835
|
+
usedPercent
|
|
836
|
+
} : {
|
|
837
|
+
id,
|
|
838
|
+
usedPercent,
|
|
839
|
+
resetsAt
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return found.size > 0 ? sortWindows(found.values()) : void 0;
|
|
843
|
+
}
|
|
844
|
+
/** A flat `{ five_hour: { utilization, resets_at }, ... }` object (Anthropic-like). */
|
|
845
|
+
function parseFlatWindows(data) {
|
|
846
|
+
const found = /* @__PURE__ */ new Map();
|
|
847
|
+
for (const [name, raw] of Object.entries(data)) {
|
|
848
|
+
if (name === "plans" || name === "data") continue;
|
|
849
|
+
const entry = asRecord$1(raw);
|
|
850
|
+
if (entry === void 0) continue;
|
|
851
|
+
const usedPercent = normalizePercent(entry.utilization ?? entry.percent ?? entry.used_percentage);
|
|
852
|
+
if (usedPercent === null) continue;
|
|
853
|
+
const id = canonicalWindowId(name);
|
|
854
|
+
if (found.has(id)) continue;
|
|
855
|
+
const resetsAt = normalizeResetAt(entry.resets_at ?? entry.reset_at ?? entry.resetsAt);
|
|
856
|
+
found.set(id, resetsAt === void 0 ? {
|
|
857
|
+
id,
|
|
858
|
+
usedPercent
|
|
859
|
+
} : {
|
|
860
|
+
id,
|
|
861
|
+
usedPercent,
|
|
862
|
+
resetsAt
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return found.size > 0 ? sortWindows(found.values()) : void 0;
|
|
866
|
+
}
|
|
867
|
+
/** Recognise the error envelopes these endpoints return with HTTP 200. */
|
|
868
|
+
function errorEnvelope(root) {
|
|
869
|
+
const nested = asRecord$1(root.error);
|
|
870
|
+
const rawCode = toFiniteNumber(root.code) ?? toFiniteNumber(nested?.code);
|
|
871
|
+
const rawMessage = typeof root.msg === "string" && root.msg || typeof root.message === "string" && root.message || typeof nested?.message === "string" && nested.message || "";
|
|
872
|
+
if (!(root.success === false || nested !== void 0)) return void 0;
|
|
873
|
+
const message = rawMessage.trim() === "" ? `error ${rawCode ?? "unknown"}` : rawMessage.trim();
|
|
874
|
+
return {
|
|
875
|
+
kind: rawCode === 1e3 ? "auth" : "http",
|
|
876
|
+
message
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
function sortWindows(windows) {
|
|
880
|
+
return [...windows].sort((a, b) => (WINDOW_RANK[a.id] ?? 99) - (WINDOW_RANK[b.id] ?? 99));
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* z.ai / Zhipu GLM coding plan. The monitor endpoint is the only documented-free
|
|
884
|
+
* source of the rolling 5h and weekly windows; the legacy and flat shapes are kept
|
|
885
|
+
* as fallbacks because the endpoint is community-reverse-engineered and has already
|
|
886
|
+
* changed shape once.
|
|
887
|
+
*/
|
|
888
|
+
function quotaRequest(base, apiKey) {
|
|
889
|
+
return {
|
|
890
|
+
url: `${base}${QUOTA_PATH}`,
|
|
891
|
+
headers: {
|
|
892
|
+
authorization: `Bearer ${apiKey}`,
|
|
893
|
+
accept: "application/json"
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
//#endregion
|
|
898
|
+
//#region src/host/sources/index.ts
|
|
899
|
+
/** Every data source shipped in v1, in the order the settings page lists them. */
|
|
900
|
+
const ALL_SOURCES = [
|
|
901
|
+
deepseek,
|
|
902
|
+
{
|
|
903
|
+
id: "zai",
|
|
904
|
+
displayName: "z.ai / GLM",
|
|
905
|
+
modes: ["coding-plan"],
|
|
906
|
+
credentialRefs: () => [
|
|
907
|
+
"ZAI_API_KEY",
|
|
908
|
+
"GLM_API_KEY",
|
|
909
|
+
"ZHIPU_API_KEY"
|
|
910
|
+
],
|
|
911
|
+
defaultBaseUrl: () => CN_BASE_URL,
|
|
912
|
+
request(input) {
|
|
913
|
+
return quotaRequest(normalizeBaseUrl(input.baseUrl) ?? CN_BASE_URL, input.apiKey);
|
|
914
|
+
},
|
|
915
|
+
/**
|
|
916
|
+
* Try the other region unless the user pinned an endpoint themselves: a declared
|
|
917
|
+
* host is a strong hint, but a wrong region guess is exactly what the mirror is
|
|
918
|
+
* there to survive.
|
|
919
|
+
*/
|
|
920
|
+
fallbackRequests(input) {
|
|
921
|
+
if (input.pinnedBaseUrl === true) return [];
|
|
922
|
+
return [quotaRequest((normalizeBaseUrl(input.baseUrl) ?? CN_BASE_URL) === GLOBAL_BASE_URL ? CN_BASE_URL : GLOBAL_BASE_URL, input.apiKey)];
|
|
923
|
+
},
|
|
924
|
+
parse(payload, _mode) {
|
|
925
|
+
const root = asRecord$1(payload);
|
|
926
|
+
if (root === void 0) throw new SourceError("parse", "z.ai quota response is not a JSON object");
|
|
927
|
+
const failure = errorEnvelope(root);
|
|
928
|
+
if (failure !== void 0) throw new SourceError(failure.kind, failure.message);
|
|
929
|
+
const windows = parseLimits(root) ?? parsePlans(root) ?? parseFlatWindows(root);
|
|
930
|
+
if (windows === void 0 || windows.length === 0) throw new SourceError("parse", "z.ai quota response contains no usable 5h/7d window");
|
|
931
|
+
return {
|
|
932
|
+
balances: [],
|
|
933
|
+
windows
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
kimi,
|
|
938
|
+
opencode,
|
|
939
|
+
sub2api
|
|
940
|
+
];
|
|
941
|
+
function findSource(id) {
|
|
942
|
+
return ALL_SOURCES.find((source) => source.id === id);
|
|
943
|
+
}
|
|
944
|
+
//#endregion
|
|
945
|
+
//#region src/host/catalog.ts
|
|
946
|
+
/** Flatten the adapters into plain JSON, because this crosses the RPC boundary. */
|
|
947
|
+
function toSourceCatalog(sources = ALL_SOURCES) {
|
|
948
|
+
return sources.map((source) => {
|
|
949
|
+
const defaultBaseUrl = {};
|
|
950
|
+
const credentialRefs = {};
|
|
951
|
+
for (const mode of source.modes) {
|
|
952
|
+
const base = source.defaultBaseUrl(mode);
|
|
953
|
+
if (base !== void 0) defaultBaseUrl[mode] = base;
|
|
954
|
+
credentialRefs[mode] = [...source.credentialRefs(mode)];
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
id: source.id,
|
|
958
|
+
displayName: source.displayName,
|
|
959
|
+
modes: [...source.modes],
|
|
960
|
+
requiresBaseUrl: source.requiresBaseUrl === true,
|
|
961
|
+
defaultBaseUrl,
|
|
962
|
+
credentialRefs
|
|
963
|
+
};
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
//#endregion
|
|
967
|
+
//#region src/host/credential-fallback.ts
|
|
968
|
+
/** Extract one ref from the `refs:` block of a credentials document. */
|
|
969
|
+
function refFromCredentialsDocument(text, ref) {
|
|
970
|
+
const lines = text.split(/\r?\n/);
|
|
971
|
+
let inRefs = false;
|
|
972
|
+
for (const line of lines) {
|
|
973
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(line)) {
|
|
974
|
+
inRefs = line.startsWith("refs:");
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (!inRefs) continue;
|
|
978
|
+
const match = /^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
|
|
979
|
+
if (match === null || match[1] !== ref) continue;
|
|
980
|
+
const raw = (match[2] ?? "").trim();
|
|
981
|
+
if (raw === "" || raw === "null" || raw === "~") return void 0;
|
|
982
|
+
const unquoted = /^(['"])(.*)\1$/.exec(raw);
|
|
983
|
+
return unquoted === null ? raw : unquoted[2] ?? "";
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function defaultCredentialsPath() {
|
|
987
|
+
const home = process.env.DSH_HOME;
|
|
988
|
+
const root = home !== void 0 && home.trim() !== "" ? home : join(homedir(), ".dsh");
|
|
989
|
+
return join(root, ".credentials.yaml");
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Wrap the platform lookup with the direct fallbacks. The platform answer always
|
|
993
|
+
* wins; the fallbacks only run when it reports "not configured".
|
|
994
|
+
*/
|
|
995
|
+
function withCredentialFallback(platform, deps = {}) {
|
|
996
|
+
const environment = deps.environment ?? process.env;
|
|
997
|
+
const path = deps.credentialsPath ?? defaultCredentialsPath();
|
|
998
|
+
const fromEnvironment = (ref) => {
|
|
999
|
+
const value = environment[ref];
|
|
1000
|
+
return typeof value === "string" && value.trim() !== "" ? {
|
|
1001
|
+
value,
|
|
1002
|
+
source: "env (direct)"
|
|
1003
|
+
} : void 0;
|
|
1004
|
+
};
|
|
1005
|
+
const fromFile = (ref) => {
|
|
1006
|
+
try {
|
|
1007
|
+
const value = refFromCredentialsDocument(readFileSync(path, "utf8"), ref);
|
|
1008
|
+
return value === void 0 ? void 0 : {
|
|
1009
|
+
value,
|
|
1010
|
+
source: "file (direct)"
|
|
1011
|
+
};
|
|
1012
|
+
} catch {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
return {
|
|
1017
|
+
resolve: async (ref) => {
|
|
1018
|
+
const platformValue = await platform.resolve(ref);
|
|
1019
|
+
if (platformValue !== void 0) return platformValue;
|
|
1020
|
+
return fromEnvironment(ref) ?? fromFile(ref);
|
|
1021
|
+
},
|
|
1022
|
+
describe: async (ref) => {
|
|
1023
|
+
const described = await platform.describe(ref);
|
|
1024
|
+
if (described.configured) return described;
|
|
1025
|
+
const fallback = fromEnvironment(ref) ?? fromFile(ref);
|
|
1026
|
+
if (fallback === void 0) return described;
|
|
1027
|
+
return {
|
|
1028
|
+
configured: true,
|
|
1029
|
+
source: fallback.source,
|
|
1030
|
+
writable: false
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
//#endregion
|
|
1036
|
+
//#region src/host/credentials.ts
|
|
1037
|
+
function clean(ref) {
|
|
1038
|
+
if (ref === void 0) return void 0;
|
|
1039
|
+
const trimmed = ref.trim();
|
|
1040
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* The refs to probe for one source+mode, most specific first: the user's explicit
|
|
1044
|
+
* override, then refs derived from the provider's own configuration, then the
|
|
1045
|
+
* source's built-in candidates.
|
|
1046
|
+
*/
|
|
1047
|
+
function orderedCredentialRefs(source, mode, options = {}) {
|
|
1048
|
+
const ordered = [];
|
|
1049
|
+
for (const candidate of [
|
|
1050
|
+
clean(options.overrideRef),
|
|
1051
|
+
...options.preferredRefs ?? [],
|
|
1052
|
+
...source.credentialRefs(mode)
|
|
1053
|
+
]) {
|
|
1054
|
+
const ref = clean(candidate);
|
|
1055
|
+
if (ref === void 0 || ordered.includes(ref)) continue;
|
|
1056
|
+
ordered.push(ref);
|
|
1057
|
+
}
|
|
1058
|
+
return ordered;
|
|
1059
|
+
}
|
|
1060
|
+
/** The first candidate that resolves to a non-blank secret wins. */
|
|
1061
|
+
async function resolveApiKey(source, mode, options, lookup) {
|
|
1062
|
+
for (const ref of orderedCredentialRefs(source, mode, options)) {
|
|
1063
|
+
const resolved = await lookup.resolve(ref);
|
|
1064
|
+
if (resolved === void 0 || resolved.value.trim() === "") continue;
|
|
1065
|
+
return {
|
|
1066
|
+
apiKey: resolved.value,
|
|
1067
|
+
ref,
|
|
1068
|
+
origin: resolved.source
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Status of every candidate, for the settings page. Never returns a secret: only
|
|
1074
|
+
* whether a ref is configured and which layer provides it.
|
|
1075
|
+
*/
|
|
1076
|
+
async function describeCredentials(source, mode, options, lookup) {
|
|
1077
|
+
const candidates = [];
|
|
1078
|
+
let resolvedRef;
|
|
1079
|
+
let resolvedSource;
|
|
1080
|
+
let resolvedWritable;
|
|
1081
|
+
for (const ref of orderedCredentialRefs(source, mode, options)) {
|
|
1082
|
+
const described = await lookup.describe(ref);
|
|
1083
|
+
const candidate = {
|
|
1084
|
+
ref,
|
|
1085
|
+
configured: described.configured,
|
|
1086
|
+
writable: described.writable
|
|
1087
|
+
};
|
|
1088
|
+
if (described.source !== void 0) candidate.source = described.source;
|
|
1089
|
+
candidates.push(candidate);
|
|
1090
|
+
if (described.configured && resolvedRef === void 0) {
|
|
1091
|
+
resolvedRef = ref;
|
|
1092
|
+
resolvedSource = described.source;
|
|
1093
|
+
resolvedWritable = described.writable;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return {
|
|
1097
|
+
candidates,
|
|
1098
|
+
configured: resolvedRef !== void 0,
|
|
1099
|
+
ref: resolvedRef,
|
|
1100
|
+
source: resolvedSource,
|
|
1101
|
+
writable: resolvedWritable
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/host/provider-refs.ts
|
|
1106
|
+
function asRecord(value) {
|
|
1107
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* The `apiKeyEnv` values the user already declared for the providers that feed a
|
|
1111
|
+
* data source. These are probed before the source's built-in ref names, so a
|
|
1112
|
+
* provider configured with a custom environment variable works without any
|
|
1113
|
+
* extra configuration in this plugin.
|
|
1114
|
+
*/
|
|
1115
|
+
function providerCredentialRefs(input) {
|
|
1116
|
+
const suggest = input.suggest ?? suggestSourceId;
|
|
1117
|
+
const refs = [];
|
|
1118
|
+
const push = (value) => {
|
|
1119
|
+
if (typeof value !== "string") return;
|
|
1120
|
+
const ref = value.trim();
|
|
1121
|
+
if (ref === "" || refs.includes(ref)) return;
|
|
1122
|
+
refs.push(ref);
|
|
1123
|
+
};
|
|
1124
|
+
if (input.sourceId === "deepseek") push(asRecord(input.deepseekSettings)?.apiKeyEnv);
|
|
1125
|
+
const providers = asRecord(asRecord(input.piAiSettings)?.providers);
|
|
1126
|
+
if (providers === void 0) return refs;
|
|
1127
|
+
for (const [providerId, raw] of Object.entries(providers)) {
|
|
1128
|
+
const profile = asRecord(raw);
|
|
1129
|
+
if (profile === void 0) continue;
|
|
1130
|
+
if (suggest(providerId, typeof profile.baseURL === "string" ? profile.baseURL : void 0) !== input.sourceId) continue;
|
|
1131
|
+
push(profile.apiKeyEnv);
|
|
1132
|
+
}
|
|
1133
|
+
return refs;
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* The endpoint each configured DSH provider declares (`llm-pi-ai` profiles carry a
|
|
1137
|
+
* `baseURL`). Used only to sharpen the data-source suggestion for providers whose
|
|
1138
|
+
* id says nothing — e.g. a relay pointed at `open.bigmodel.cn` is a z.ai account.
|
|
1139
|
+
*/
|
|
1140
|
+
function providerEndpointHints(piAiSettings) {
|
|
1141
|
+
const providers = asRecord(asRecord(piAiSettings)?.providers);
|
|
1142
|
+
const hints = {};
|
|
1143
|
+
if (providers === void 0) return hints;
|
|
1144
|
+
for (const [providerId, raw] of Object.entries(providers)) {
|
|
1145
|
+
const profile = asRecord(raw);
|
|
1146
|
+
const baseUrl = profile === void 0 ? void 0 : profile.baseURL;
|
|
1147
|
+
if (typeof baseUrl !== "string") continue;
|
|
1148
|
+
const trimmed = baseUrl.trim();
|
|
1149
|
+
if (trimmed !== "") hints[providerId] = trimmed;
|
|
1150
|
+
}
|
|
1151
|
+
return hints;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* A rejected key and a broken endpoint need different copy in the UI, so HTTP
|
|
1155
|
+
* statuses are classified rather than collapsed into one "failed" bucket.
|
|
1156
|
+
*/
|
|
1157
|
+
function failureKindForStatus(status) {
|
|
1158
|
+
if (status === 401 || status === 403) return "auth";
|
|
1159
|
+
return "http";
|
|
1160
|
+
}
|
|
1161
|
+
function messageOf(error) {
|
|
1162
|
+
if (error instanceof Error) return error.message;
|
|
1163
|
+
return String(error);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* The whole network path for one reading: ask the adapter for a request, send it,
|
|
1167
|
+
* decode JSON, hand the payload back to the adapter's pure parser, and translate
|
|
1168
|
+
* every failure into a `SourceError` the UI knows how to phrase.
|
|
1169
|
+
*/
|
|
1170
|
+
async function readUsage(input, deps) {
|
|
1171
|
+
const requestInput = {
|
|
1172
|
+
mode: input.mode,
|
|
1173
|
+
apiKey: input.apiKey,
|
|
1174
|
+
...input.baseUrl === void 0 ? {} : { baseUrl: input.baseUrl },
|
|
1175
|
+
...input.pinnedBaseUrl === true ? { pinnedBaseUrl: true } : {}
|
|
1176
|
+
};
|
|
1177
|
+
const candidates = [input.source.request(requestInput), ...input.source.fallbackRequests?.(requestInput) ?? []];
|
|
1178
|
+
const failures = [];
|
|
1179
|
+
for (const [index, request] of candidates.entries()) try {
|
|
1180
|
+
const payload = await fetchPayload(request, deps);
|
|
1181
|
+
return input.source.parse(payload, input.mode);
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
const failure = toSourceError(error);
|
|
1184
|
+
failures.push(failure);
|
|
1185
|
+
if (index === candidates.length - 1) break;
|
|
1186
|
+
}
|
|
1187
|
+
throw failures.find((failure) => failure.kind !== "network") ?? failures[0] ?? new SourceError("network", "no endpoint tried");
|
|
1188
|
+
}
|
|
1189
|
+
function toSourceError(error) {
|
|
1190
|
+
return error instanceof SourceError ? error : new SourceError("network", messageOf(error));
|
|
1191
|
+
}
|
|
1192
|
+
async function fetchPayload(request, deps) {
|
|
1193
|
+
const timeoutMs = deps.timeoutMs ?? 15e3;
|
|
1194
|
+
const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0;
|
|
1195
|
+
let response;
|
|
1196
|
+
try {
|
|
1197
|
+
response = await deps.fetch(request.url, signal === void 0 ? { headers: request.headers } : {
|
|
1198
|
+
headers: request.headers,
|
|
1199
|
+
signal
|
|
1200
|
+
});
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
throw new SourceError("network", messageOf(error));
|
|
1203
|
+
}
|
|
1204
|
+
if (!response.ok) throw new SourceError(failureKindForStatus(response.status), `HTTP ${response.status}`);
|
|
1205
|
+
try {
|
|
1206
|
+
return await response.json();
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
throw new SourceError("parse", `invalid JSON: ${messageOf(error)}`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
/** Adapt `readUsage` to the shape `UsageStateStore` expects. */
|
|
1212
|
+
function createTargetReader(deps) {
|
|
1213
|
+
return (target, credentials, source) => readUsage({
|
|
1214
|
+
source,
|
|
1215
|
+
mode: target.mode,
|
|
1216
|
+
apiKey: credentials.apiKey,
|
|
1217
|
+
...credentials.baseUrl === void 0 ? {} : { baseUrl: credentials.baseUrl },
|
|
1218
|
+
...credentials.baseUrlPinned === true ? { pinnedBaseUrl: true } : {}
|
|
1219
|
+
}, deps);
|
|
1220
|
+
}
|
|
1221
|
+
//#endregion
|
|
1222
|
+
//#region src/host/refresh.ts
|
|
1223
|
+
function toSnapshotError(error) {
|
|
1224
|
+
if (error instanceof SourceError) return error.message === "" ? { kind: error.kind } : {
|
|
1225
|
+
kind: error.kind,
|
|
1226
|
+
detail: error.message
|
|
1227
|
+
};
|
|
1228
|
+
if (error instanceof Error) return error.message === "" ? { kind: "unknown" } : {
|
|
1229
|
+
kind: "unknown",
|
|
1230
|
+
detail: error.message
|
|
1231
|
+
};
|
|
1232
|
+
return { kind: "unknown" };
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Holds the last known reading of every configured source and decides when to
|
|
1236
|
+
* refresh it.
|
|
1237
|
+
*
|
|
1238
|
+
* The rules that matter for a plugin polling somebody else's billing endpoint:
|
|
1239
|
+
* a reading that succeeded is reused until the minimum interval has passed; a
|
|
1240
|
+
* failed attempt is never throttled (a retry may follow immediately); concurrent
|
|
1241
|
+
* callers share one in-flight request; and a failure never replaces a good
|
|
1242
|
+
* reading — it only marks it stale, so the status line can never show invented
|
|
1243
|
+
* numbers.
|
|
1244
|
+
*/
|
|
1245
|
+
var UsageStateStore = class {
|
|
1246
|
+
deps;
|
|
1247
|
+
readings = /* @__PURE__ */ new Map();
|
|
1248
|
+
succeededAt = /* @__PURE__ */ new Map();
|
|
1249
|
+
inflight = /* @__PURE__ */ new Map();
|
|
1250
|
+
turnEndCancel;
|
|
1251
|
+
idleCancel;
|
|
1252
|
+
constructor(deps) {
|
|
1253
|
+
this.deps = deps;
|
|
1254
|
+
}
|
|
1255
|
+
/** Everything known so far, keyed by target key. */
|
|
1256
|
+
snapshots() {
|
|
1257
|
+
const result = {};
|
|
1258
|
+
for (const [key, snapshot] of this.readings) result[key] = snapshot;
|
|
1259
|
+
return result;
|
|
1260
|
+
}
|
|
1261
|
+
snapshot(key) {
|
|
1262
|
+
return this.readings.get(key);
|
|
1263
|
+
}
|
|
1264
|
+
/** Refresh every configured target; failures are captured, never thrown. */
|
|
1265
|
+
async refreshAll() {
|
|
1266
|
+
await Promise.all(this.deps.targets().map((target) => this.refresh(target.key)));
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Refresh one target. Returns the cached snapshot when the minimum interval has
|
|
1270
|
+
* not elapsed yet, unless `force` is set.
|
|
1271
|
+
*/
|
|
1272
|
+
async refresh(key, options = {}) {
|
|
1273
|
+
const target = this.deps.targets().find((candidate) => candidate.key === key);
|
|
1274
|
+
if (target === void 0) return this.fail({
|
|
1275
|
+
key,
|
|
1276
|
+
sourceId: key.split(":")[0] ?? "",
|
|
1277
|
+
mode: "api"
|
|
1278
|
+
}, {
|
|
1279
|
+
kind: "config",
|
|
1280
|
+
detail: `unknown target ${key}`
|
|
1281
|
+
}, this.readings.get(key));
|
|
1282
|
+
const cached = this.readings.get(key);
|
|
1283
|
+
const lastSuccess = this.succeededAt.get(key);
|
|
1284
|
+
if (options.force !== true && cached !== void 0 && cached.error === void 0 && lastSuccess !== void 0 && this.deps.clock.now() - lastSuccess < this.deps.policy().minIntervalSeconds * 1e3) return cached;
|
|
1285
|
+
const running = this.inflight.get(key);
|
|
1286
|
+
if (running !== void 0) return running;
|
|
1287
|
+
const promise = this.run(target).finally(() => {
|
|
1288
|
+
this.inflight.delete(key);
|
|
1289
|
+
});
|
|
1290
|
+
this.inflight.set(key, promise);
|
|
1291
|
+
return promise;
|
|
1292
|
+
}
|
|
1293
|
+
async run(target) {
|
|
1294
|
+
const previous = this.readings.get(target.key);
|
|
1295
|
+
const source = this.deps.findSource(target.sourceId);
|
|
1296
|
+
if (source === void 0) return this.fail(target, {
|
|
1297
|
+
kind: "config",
|
|
1298
|
+
detail: `unknown source ${target.sourceId}`
|
|
1299
|
+
}, previous);
|
|
1300
|
+
let credential;
|
|
1301
|
+
try {
|
|
1302
|
+
credential = await this.deps.credentials.resolve(target);
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
return this.fail(target, toSnapshotError(error), previous);
|
|
1305
|
+
}
|
|
1306
|
+
if (credential === void 0) return this.fail(target, {
|
|
1307
|
+
kind: "config",
|
|
1308
|
+
detail: "no credential configured"
|
|
1309
|
+
}, previous);
|
|
1310
|
+
try {
|
|
1311
|
+
const reading = await this.deps.read(target, credential, source);
|
|
1312
|
+
const fetchedAt = this.deps.clock.now();
|
|
1313
|
+
const snapshot = {
|
|
1314
|
+
sourceId: target.sourceId,
|
|
1315
|
+
mode: target.mode,
|
|
1316
|
+
balances: reading.balances,
|
|
1317
|
+
windows: reading.windows,
|
|
1318
|
+
fetchedAt
|
|
1319
|
+
};
|
|
1320
|
+
this.readings.set(target.key, snapshot);
|
|
1321
|
+
this.succeededAt.set(target.key, fetchedAt);
|
|
1322
|
+
return snapshot;
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
return this.fail(target, toSnapshotError(error), previous);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
fail(target, error, previous) {
|
|
1328
|
+
const snapshot = previous === void 0 ? {
|
|
1329
|
+
sourceId: target.sourceId,
|
|
1330
|
+
mode: target.mode,
|
|
1331
|
+
balances: [],
|
|
1332
|
+
windows: [],
|
|
1333
|
+
fetchedAt: this.deps.clock.now(),
|
|
1334
|
+
error
|
|
1335
|
+
} : {
|
|
1336
|
+
...previous,
|
|
1337
|
+
stale: true,
|
|
1338
|
+
error
|
|
1339
|
+
};
|
|
1340
|
+
this.readings.set(target.key, snapshot);
|
|
1341
|
+
return snapshot;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* A turn just finished: refresh shortly, so the provider has time to settle the
|
|
1345
|
+
* request we just paid for. Repeated calls within the window collapse into one.
|
|
1346
|
+
*/
|
|
1347
|
+
onTurnEnd() {
|
|
1348
|
+
if (this.turnEndCancel !== void 0) return;
|
|
1349
|
+
const cancel = this.deps.clock.after(this.deps.policy().turnEndDelayMs, () => {
|
|
1350
|
+
this.turnEndCancel = void 0;
|
|
1351
|
+
this.refreshAll();
|
|
1352
|
+
});
|
|
1353
|
+
this.turnEndCancel = cancel;
|
|
1354
|
+
}
|
|
1355
|
+
/** Start the idle fallback timer; returns a stop function. */
|
|
1356
|
+
start() {
|
|
1357
|
+
if (this.idleCancel === void 0) {
|
|
1358
|
+
const intervalMs = this.deps.policy().intervalMinutes * 6e4;
|
|
1359
|
+
this.idleCancel = this.deps.clock.every(intervalMs, () => {
|
|
1360
|
+
this.refreshAll();
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
return () => this.stop();
|
|
1364
|
+
}
|
|
1365
|
+
stop() {
|
|
1366
|
+
this.turnEndCancel?.();
|
|
1367
|
+
this.turnEndCancel = void 0;
|
|
1368
|
+
this.idleCancel?.();
|
|
1369
|
+
this.idleCancel = void 0;
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region src/host/service.ts
|
|
1374
|
+
/** Cordis service key the RPC gateway resolves. */
|
|
1375
|
+
const USAGE_STATE_SERVICE = "usageState";
|
|
1376
|
+
/** RPC namespace; the wire endpoint is `<namespace>/<method>`. */
|
|
1377
|
+
const USAGE_STATE_RPC_NAMESPACE = "usageState";
|
|
1378
|
+
/**
|
|
1379
|
+
* The browser-facing face of the plugin. Tiny on purpose: one poll method and one
|
|
1380
|
+
* credential-status method. Configuration is *not* here — the settings namespace
|
|
1381
|
+
* already carries it to the browser over the platform's own transport.
|
|
1382
|
+
*/
|
|
1383
|
+
var UsageStateService = class {
|
|
1384
|
+
deps;
|
|
1385
|
+
constructor(deps) {
|
|
1386
|
+
this.deps = deps;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Refresh what is due and report every reading. Forcing bypasses the minimum
|
|
1390
|
+
* interval, so it is reserved for the explicit "refresh now" affordance.
|
|
1391
|
+
*
|
|
1392
|
+
* Settled rather than all: one unreachable provider must not blank the whole
|
|
1393
|
+
* status line for the others.
|
|
1394
|
+
*/
|
|
1395
|
+
async getState(force) {
|
|
1396
|
+
const options = force === true ? { force: true } : {};
|
|
1397
|
+
await Promise.allSettled(this.deps.targets().map((target) => this.deps.store.refresh(target.key, options)));
|
|
1398
|
+
return {
|
|
1399
|
+
sources: this.deps.catalog(),
|
|
1400
|
+
snapshots: this.deps.store.snapshots(),
|
|
1401
|
+
checkedAt: this.deps.now()
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
/** Credential status per target key, never a secret value. */
|
|
1405
|
+
async describeCredentials() {
|
|
1406
|
+
const credentials = {};
|
|
1407
|
+
for (const target of this.deps.targets()) credentials[target.key] = await this.deps.describe(target);
|
|
1408
|
+
return { credentials };
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
//#endregion
|
|
1412
|
+
//#region src/host/settings.ts
|
|
1413
|
+
/** Settings namespace owned by this plugin; must match `/^[a-z][a-z0-9-]*$/`. */
|
|
1414
|
+
const USAGE_STATE_NS = "usage-state";
|
|
1415
|
+
const usageStateSchema = Object.assign((section) => normalizeConfig(section), { toJSON: () => ({
|
|
1416
|
+
uid: 1,
|
|
1417
|
+
refs: { 1: {
|
|
1418
|
+
type: "object",
|
|
1419
|
+
meta: {},
|
|
1420
|
+
dict: {}
|
|
1421
|
+
} }
|
|
1422
|
+
}) });
|
|
1423
|
+
/**
|
|
1424
|
+
* Register the namespace and keep a live in-process snapshot of it.
|
|
1425
|
+
*
|
|
1426
|
+
* `ctx.inject` is the graceful-degradation boundary: on a host without a settings
|
|
1427
|
+
* provider the callback never runs and the plugin's own defaults stand. `applies`
|
|
1428
|
+
* is metadata in this DSH version — what actually makes a change live is the
|
|
1429
|
+
* `watch` subscription below.
|
|
1430
|
+
*/
|
|
1431
|
+
function installUsageStateSettings(ctx, onConfig) {
|
|
1432
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
1433
|
+
const scope = settingsCtx.settings.register(USAGE_STATE_NS, usageStateSchema, { applies: "live" });
|
|
1434
|
+
onConfig(normalizeConfig(scope.get()));
|
|
1435
|
+
scope.watch((next) => onConfig(normalizeConfig(next)));
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
//#endregion
|
|
1439
|
+
//#region src/shared/providers.ts
|
|
1440
|
+
/**
|
|
1441
|
+
* The scheme+host of a declared endpoint. A DSH provider profile's `baseURL` is an
|
|
1442
|
+
* *API* base (`https://api.z.ai/api/paas/v4`), while every adapter here appends its
|
|
1443
|
+
* own path to a bare origin, so only the origin is usable as our endpoint.
|
|
1444
|
+
*/
|
|
1445
|
+
function originOf(url) {
|
|
1446
|
+
if (url === void 0) return void 0;
|
|
1447
|
+
try {
|
|
1448
|
+
const parsed = new URL(url.trim());
|
|
1449
|
+
return parsed.origin === "null" ? void 0 : parsed.origin;
|
|
1450
|
+
} catch {
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
function withTarget(resolution) {
|
|
1455
|
+
if (resolution.sourceId === null || resolution.mode === null) return resolution;
|
|
1456
|
+
return {
|
|
1457
|
+
...resolution,
|
|
1458
|
+
key: `${resolution.sourceId}:${resolution.mode}`
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Resolve one provider. `auto` picks the source the provider id (or its endpoint)
|
|
1463
|
+
* suggests and that source's primary mode, so a provider DSH already has a key
|
|
1464
|
+
* for starts working without touching the settings page.
|
|
1465
|
+
*/
|
|
1466
|
+
function resolveProvider(input) {
|
|
1467
|
+
const entry = input.config.providers[input.provider];
|
|
1468
|
+
const declared = originOf(input.endpointHint);
|
|
1469
|
+
const baseUrl = entry?.baseUrl ?? declared;
|
|
1470
|
+
const base = {
|
|
1471
|
+
provider: input.provider,
|
|
1472
|
+
...baseUrl === void 0 ? {} : { baseUrl },
|
|
1473
|
+
...entry?.baseUrl === void 0 ? {} : { baseUrlPinned: true },
|
|
1474
|
+
...entry?.apiKeyRef === void 0 ? {} : { apiKeyRef: entry.apiKeyRef }
|
|
1475
|
+
};
|
|
1476
|
+
if (entry?.mode === "hidden") return {
|
|
1477
|
+
...base,
|
|
1478
|
+
sourceId: null,
|
|
1479
|
+
mode: null,
|
|
1480
|
+
reason: "hidden"
|
|
1481
|
+
};
|
|
1482
|
+
const sourceId = entry?.sourceId ?? suggestSourceId(input.provider, input.endpointHint) ?? null;
|
|
1483
|
+
if (sourceId === null) return {
|
|
1484
|
+
...base,
|
|
1485
|
+
sourceId: null,
|
|
1486
|
+
mode: null,
|
|
1487
|
+
reason: "unknown-source"
|
|
1488
|
+
};
|
|
1489
|
+
const source = input.catalog.find((candidate) => candidate.id === sourceId);
|
|
1490
|
+
if (source === void 0) return {
|
|
1491
|
+
...base,
|
|
1492
|
+
sourceId,
|
|
1493
|
+
mode: null,
|
|
1494
|
+
reason: "unknown-source"
|
|
1495
|
+
};
|
|
1496
|
+
if (entry?.mode === "api" || entry?.mode === "coding-plan") {
|
|
1497
|
+
if (!source.modes.includes(entry.mode)) return {
|
|
1498
|
+
...base,
|
|
1499
|
+
sourceId,
|
|
1500
|
+
mode: null,
|
|
1501
|
+
reason: "unsupported"
|
|
1502
|
+
};
|
|
1503
|
+
return withTarget({
|
|
1504
|
+
...base,
|
|
1505
|
+
sourceId,
|
|
1506
|
+
mode: entry.mode,
|
|
1507
|
+
reason: "configured"
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
if (source.requiresBaseUrl && baseUrl === void 0) return {
|
|
1511
|
+
...base,
|
|
1512
|
+
sourceId,
|
|
1513
|
+
mode: null,
|
|
1514
|
+
reason: "needs-endpoint"
|
|
1515
|
+
};
|
|
1516
|
+
const mode = source.modes[0];
|
|
1517
|
+
if (mode === void 0) return {
|
|
1518
|
+
...base,
|
|
1519
|
+
sourceId,
|
|
1520
|
+
mode: null,
|
|
1521
|
+
reason: "unsupported"
|
|
1522
|
+
};
|
|
1523
|
+
return withTarget({
|
|
1524
|
+
...base,
|
|
1525
|
+
sourceId,
|
|
1526
|
+
mode,
|
|
1527
|
+
reason: "auto"
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
//#endregion
|
|
1531
|
+
//#region src/host/targets.ts
|
|
1532
|
+
function targetKey(sourceId, mode) {
|
|
1533
|
+
return `${sourceId}:${mode}`;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* The set of readings the plugin should keep fresh, in display order.
|
|
1537
|
+
*
|
|
1538
|
+
* Balance and quota are account-level, so several providers (or legacy per-model
|
|
1539
|
+
* entries) sharing one source and mode collapse into a single target — the status
|
|
1540
|
+
* line never shows the same account twice. Providers that are hidden, map to no
|
|
1541
|
+
* known source, or ask for a mode the source cannot serve are skipped silently.
|
|
1542
|
+
*/
|
|
1543
|
+
function resolveTargets(config, options = {}) {
|
|
1544
|
+
const sources = options.sources ?? ALL_SOURCES;
|
|
1545
|
+
const catalog = toSourceCatalog(sources);
|
|
1546
|
+
const targets = [];
|
|
1547
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1548
|
+
const push = (sourceId, mode, endpoint) => {
|
|
1549
|
+
const source = sources.find((candidate) => candidate.id === sourceId);
|
|
1550
|
+
if (source === void 0 || !source.modes.includes(mode)) return;
|
|
1551
|
+
const key = targetKey(sourceId, mode);
|
|
1552
|
+
if (seen.has(key)) return;
|
|
1553
|
+
seen.add(key);
|
|
1554
|
+
targets.push({
|
|
1555
|
+
key,
|
|
1556
|
+
sourceId,
|
|
1557
|
+
mode,
|
|
1558
|
+
...endpoint?.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
|
|
1559
|
+
...endpoint?.pinned === true ? { baseUrlPinned: true } : {}
|
|
1560
|
+
});
|
|
1561
|
+
};
|
|
1562
|
+
/**
|
|
1563
|
+
* The live runtime's provider ids, when we have them. An empty list means "could
|
|
1564
|
+
* not ask" (the `llm` service is missing on early ticks), not "nothing exists", so
|
|
1565
|
+
* it must not suppress the configured entries.
|
|
1566
|
+
*/
|
|
1567
|
+
const live = options.providers !== void 0 && options.providers.length > 0 ? new Set(options.providers) : void 0;
|
|
1568
|
+
/**
|
|
1569
|
+
* Whether a stored entry may still produce requests. The entry itself is kept — a
|
|
1570
|
+
* provider that comes back keeps the mode the user chose for it — but a provider
|
|
1571
|
+
* DSH no longer has must not keep an account being polled behind an invisible row.
|
|
1572
|
+
*/
|
|
1573
|
+
const pollable = (provider) => live === void 0 || live.has(provider);
|
|
1574
|
+
for (const entry of config.models) {
|
|
1575
|
+
if (entry.mode === "hidden" || entry.sourceId === null) continue;
|
|
1576
|
+
if (!pollable(entry.provider)) continue;
|
|
1577
|
+
push(entry.sourceId, entry.mode);
|
|
1578
|
+
}
|
|
1579
|
+
const providers = /* @__PURE__ */ new Set();
|
|
1580
|
+
for (const provider of [...config.order, ...Object.keys(config.providers)]) if (pollable(provider)) providers.add(provider);
|
|
1581
|
+
for (const provider of options.providers ?? []) providers.add(provider);
|
|
1582
|
+
for (const provider of providers) {
|
|
1583
|
+
const resolution = resolveProvider({
|
|
1584
|
+
provider,
|
|
1585
|
+
config,
|
|
1586
|
+
catalog,
|
|
1587
|
+
...options.endpointHints?.[provider] === void 0 ? {} : { endpointHint: options.endpointHints[provider] }
|
|
1588
|
+
});
|
|
1589
|
+
if (resolution.sourceId !== null && resolution.mode !== null) push(resolution.sourceId, resolution.mode, {
|
|
1590
|
+
...resolution.baseUrl === void 0 ? {} : { baseUrl: resolution.baseUrl },
|
|
1591
|
+
...resolution.baseUrlPinned === true ? { pinned: true } : {}
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
return targets;
|
|
1595
|
+
}
|
|
1596
|
+
//#endregion
|
|
1597
|
+
//#region src/index.ts
|
|
1598
|
+
const name = "usage-state";
|
|
1599
|
+
/** `timer` is what provides `ctx.timeout` / `ctx.interval`. */
|
|
1600
|
+
const inject = ["timer"];
|
|
1601
|
+
function readNamespace(ctx, namespace) {
|
|
1602
|
+
const settings = ctx.get("settings");
|
|
1603
|
+
if (settings?.get === void 0) return void 0;
|
|
1604
|
+
try {
|
|
1605
|
+
return settings.get(namespace);
|
|
1606
|
+
} catch {
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Credential access through the host's credential provider. A provider that is
|
|
1612
|
+
* absent or throws must degrade to "not configured" — never to a crash inside a
|
|
1613
|
+
* refresh loop.
|
|
1614
|
+
*/
|
|
1615
|
+
function createCredentialLookup(ctx, fallback = {}) {
|
|
1616
|
+
const provider = ctx.get("credentials");
|
|
1617
|
+
const platform = {
|
|
1618
|
+
resolve: async (ref) => {
|
|
1619
|
+
if (provider === void 0) return void 0;
|
|
1620
|
+
try {
|
|
1621
|
+
return await provider.resolve(ref);
|
|
1622
|
+
} catch {
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
describe: async (ref) => {
|
|
1627
|
+
if (provider === void 0) return {
|
|
1628
|
+
configured: false,
|
|
1629
|
+
writable: false
|
|
1630
|
+
};
|
|
1631
|
+
try {
|
|
1632
|
+
return await provider.describe(ref);
|
|
1633
|
+
} catch {
|
|
1634
|
+
return {
|
|
1635
|
+
configured: false,
|
|
1636
|
+
writable: false
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
return fallback === false ? platform : withCredentialFallback(platform, fallback);
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Publish the service under the name the RPC gateway resolves.
|
|
1645
|
+
*
|
|
1646
|
+
* The binding shape is exact and unforgiving (`dsh-api-gateway`'s `readBinding`):
|
|
1647
|
+
* - `service` must be the service OBJECT itself, not its name;
|
|
1648
|
+
* - `serviceKey` must be the registered name;
|
|
1649
|
+
* - `namespace` must equal the RPC namespace.
|
|
1650
|
+
* A mismatch fails every dispatch with `gateway/binding-invalid` — which looks
|
|
1651
|
+
* like "the plugin silently returns nothing" from the browser. Non-enumerable so
|
|
1652
|
+
* it stays out of any serialization of the service.
|
|
1653
|
+
*/
|
|
1654
|
+
function provideUsageState(ctx, service) {
|
|
1655
|
+
Object.defineProperty(service, "typertRemote", {
|
|
1656
|
+
configurable: false,
|
|
1657
|
+
enumerable: false,
|
|
1658
|
+
writable: false,
|
|
1659
|
+
value: {
|
|
1660
|
+
service,
|
|
1661
|
+
serviceKey: USAGE_STATE_SERVICE,
|
|
1662
|
+
namespace: USAGE_STATE_RPC_NAMESPACE
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
ctx.provide(USAGE_STATE_SERVICE, service);
|
|
1666
|
+
return service;
|
|
1667
|
+
}
|
|
1668
|
+
function isTurnEnd(event) {
|
|
1669
|
+
return event !== null && typeof event === "object" && event.type === "turn/end";
|
|
1670
|
+
}
|
|
1671
|
+
/** Wire the whole host half; exported so tests can drive it with a fake context. */
|
|
1672
|
+
function createUsageState(ctx, deps = {}) {
|
|
1673
|
+
const now = deps.now ?? (() => Date.now());
|
|
1674
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
1675
|
+
const catalog = toSourceCatalog(ALL_SOURCES);
|
|
1676
|
+
const lookup = createCredentialLookup(ctx, deps.credentialFallback ?? {});
|
|
1677
|
+
let config = DEFAULT_CONFIG;
|
|
1678
|
+
installUsageStateSettings(ctx, (next) => {
|
|
1679
|
+
config = next;
|
|
1680
|
+
});
|
|
1681
|
+
/**
|
|
1682
|
+
* The plugin's own configuration says nothing about which providers exist, so
|
|
1683
|
+
* ask the host's LLM runtime. This is what makes zero configuration work: a
|
|
1684
|
+
* provider nobody configured still resolves to its suggested source.
|
|
1685
|
+
*/
|
|
1686
|
+
const providerFacts = () => {
|
|
1687
|
+
const llm = ctx.get("llm");
|
|
1688
|
+
const ids = [];
|
|
1689
|
+
try {
|
|
1690
|
+
for (const provider of llm?.listProviders?.() ?? []) if (typeof provider?.id === "string" && provider.id !== "") ids.push(provider.id);
|
|
1691
|
+
} catch {}
|
|
1692
|
+
return {
|
|
1693
|
+
ids,
|
|
1694
|
+
hints: providerEndpointHints(readNamespace(ctx, "llm-pi-ai"))
|
|
1695
|
+
};
|
|
1696
|
+
};
|
|
1697
|
+
const targets = () => {
|
|
1698
|
+
const { ids, hints } = providerFacts();
|
|
1699
|
+
return resolveTargets(config, {
|
|
1700
|
+
providers: ids,
|
|
1701
|
+
endpointHints: hints
|
|
1702
|
+
});
|
|
1703
|
+
};
|
|
1704
|
+
const optionsFor = (target) => {
|
|
1705
|
+
const overrideRef = config.sources[target.sourceId]?.apiKeyRef;
|
|
1706
|
+
return {
|
|
1707
|
+
...overrideRef === void 0 ? {} : { overrideRef },
|
|
1708
|
+
preferredRefs: providerCredentialRefs({
|
|
1709
|
+
sourceId: target.sourceId,
|
|
1710
|
+
deepseekSettings: readNamespace(ctx, "llm-deepseek"),
|
|
1711
|
+
piAiSettings: readNamespace(ctx, "llm-pi-ai")
|
|
1712
|
+
})
|
|
1713
|
+
};
|
|
1714
|
+
};
|
|
1715
|
+
const store = new UsageStateStore({
|
|
1716
|
+
clock: {
|
|
1717
|
+
now,
|
|
1718
|
+
after: (ms, fn) => ctx.timeout(fn, ms),
|
|
1719
|
+
every: (ms, fn) => ctx.interval(fn, ms)
|
|
1720
|
+
},
|
|
1721
|
+
policy: () => config.refresh,
|
|
1722
|
+
targets,
|
|
1723
|
+
findSource,
|
|
1724
|
+
credentials: { resolve: async (target) => {
|
|
1725
|
+
const source = findSource(target.sourceId);
|
|
1726
|
+
if (source === void 0) return void 0;
|
|
1727
|
+
const resolved = await resolveApiKey(source, target.mode, optionsFor(target), lookup);
|
|
1728
|
+
if (resolved === void 0) return void 0;
|
|
1729
|
+
const pinnedBaseUrl = config.sources[target.sourceId]?.baseUrl;
|
|
1730
|
+
const baseUrl = pinnedBaseUrl ?? target.baseUrl;
|
|
1731
|
+
if (baseUrl === void 0) return resolved;
|
|
1732
|
+
return {
|
|
1733
|
+
...resolved,
|
|
1734
|
+
baseUrl,
|
|
1735
|
+
...pinnedBaseUrl === void 0 ? {} : { baseUrlPinned: true }
|
|
1736
|
+
};
|
|
1737
|
+
} },
|
|
1738
|
+
read: createTargetReader({ fetch: fetchImpl })
|
|
1739
|
+
});
|
|
1740
|
+
const service = new UsageStateService({
|
|
1741
|
+
store,
|
|
1742
|
+
targets,
|
|
1743
|
+
catalog: () => catalog,
|
|
1744
|
+
describe: async (target) => {
|
|
1745
|
+
const source = findSource(target.sourceId);
|
|
1746
|
+
if (source === void 0) return {
|
|
1747
|
+
candidates: [],
|
|
1748
|
+
configured: false
|
|
1749
|
+
};
|
|
1750
|
+
return describeCredentials(source, target.mode, optionsFor(target), lookup);
|
|
1751
|
+
},
|
|
1752
|
+
now
|
|
1753
|
+
});
|
|
1754
|
+
ctx.on("session/event", (...args) => {
|
|
1755
|
+
if (isTurnEnd(args[1])) store.onTurnEnd();
|
|
1756
|
+
});
|
|
1757
|
+
store.start();
|
|
1758
|
+
return provideUsageState(ctx, service);
|
|
1759
|
+
}
|
|
1760
|
+
function apply(ctx) {
|
|
1761
|
+
createUsageState(ctx);
|
|
1762
|
+
}
|
|
1763
|
+
//#endregion
|
|
1764
|
+
export { apply, createCredentialLookup, createUsageState, inject, name, provideUsageState };
|