@gamegeek-saikel/dsh-cost-meter 0.1.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.md +116 -0
- package/README.zh-CN.md +116 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +1481 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +2403 -0
- package/lib/invariant.js +38 -0
- package/lib/types/client/AssistantCostChip.d.ts +29 -0
- package/lib/types/client/AssistantCostChip.d.ts.map +1 -0
- package/lib/types/client/AssistantCostChip.js +75 -0
- package/lib/types/client/CostPluginCard.d.ts +10 -0
- package/lib/types/client/CostPluginCard.d.ts.map +1 -0
- package/lib/types/client/CostPluginCard.js +124 -0
- package/lib/types/client/CostView.d.ts +10 -0
- package/lib/types/client/CostView.d.ts.map +1 -0
- package/lib/types/client/CostView.js +66 -0
- package/lib/types/client/SessionCostLine.d.ts +21 -0
- package/lib/types/client/SessionCostLine.d.ts.map +1 -0
- package/lib/types/client/SessionCostLine.js +173 -0
- package/lib/types/client/SessionCostPill.d.ts +13 -0
- package/lib/types/client/SessionCostPill.d.ts.map +1 -0
- package/lib/types/client/SessionCostPill.js +123 -0
- package/lib/types/client/cost-math.d.ts +88 -0
- package/lib/types/client/cost-math.d.ts.map +1 -0
- package/lib/types/client/cost-math.js +199 -0
- package/lib/types/client/format.d.ts +35 -0
- package/lib/types/client/format.d.ts.map +1 -0
- package/lib/types/client/format.js +61 -0
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +59 -0
- package/lib/types/client/locales.d.ts +78 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +149 -0
- package/lib/types/index.d.ts +136 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +401 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +38 -0
- package/lib/types/pricebook.d.ts +467 -0
- package/lib/types/pricebook.d.ts.map +1 -0
- package/lib/types/pricebook.js +696 -0
- package/lib/types/pricing.d.ts +93 -0
- package/lib/types/pricing.d.ts.map +1 -0
- package/lib/types/pricing.js +346 -0
- package/lib/types/session-cost-projection.d.ts +70 -0
- package/lib/types/session-cost-projection.d.ts.map +1 -0
- package/lib/types/session-cost-projection.js +198 -0
- package/lib/types/subagent-cost.d.ts +51 -0
- package/lib/types/subagent-cost.d.ts.map +1 -0
- package/lib/types/subagent-cost.js +79 -0
- package/lib/types/types.d.ts +334 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +6 -0
- package/package.json +134 -0
- package/src/client/AssistantCostChip.module.css +81 -0
- package/src/client/AssistantCostChip.tsx +109 -0
- package/src/client/CostPluginCard.module.css +186 -0
- package/src/client/CostPluginCard.tsx +264 -0
- package/src/client/CostView.module.css +168 -0
- package/src/client/CostView.tsx +145 -0
- package/src/client/SessionCostLine.module.css +19 -0
- package/src/client/SessionCostLine.tsx +186 -0
- package/src/client/SessionCostPill.module.css +118 -0
- package/src/client/SessionCostPill.tsx +192 -0
- package/src/client/cost-math.ts +230 -0
- package/src/client/format.ts +63 -0
- package/src/client/index.ts +108 -0
- package/src/client/locales.ts +165 -0
- package/src/css-modules.d.ts +4 -0
- package/src/index.ts +489 -0
- package/src/invariant.ts +47 -0
- package/src/pricebook.ts +825 -0
- package/src/pricing.ts +378 -0
- package/src/session-cost-projection.ts +238 -0
- package/src/subagent-cost.ts +105 -0
- package/src/types.ts +364 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cost-meter host half: serves the DeepSeek account balance
|
|
3
|
+
* (official `GET /user/balance`), the pricebook (persisted, snapshot-anchored
|
|
4
|
+
* per-model pricing over the official page / built-in fallback / OpenRouter
|
|
5
|
+
* chain), and drives the `sessionCost` projection that folds every usage
|
|
6
|
+
* event into an immutable per-step cost ledger.
|
|
7
|
+
*
|
|
8
|
+
* The API key resolves per refresh through the credential seam; the balance
|
|
9
|
+
* is cached for `refreshMs` to stay polite to the provider's rate limits;
|
|
10
|
+
* the pricebook refresh cadence is `pricingRefreshHours`. The `/cost-meter`
|
|
11
|
+
* route serves the balance + pricebook view (GET) and applies settings
|
|
12
|
+
* mutations and manual refreshes (POST, trust-fenced like the balance route).
|
|
13
|
+
*
|
|
14
|
+
* @module @gamegeek-saikel/dsh-cost-meter
|
|
15
|
+
*/
|
|
16
|
+
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
18
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
19
|
+
import { DEFAULT_ALIASES, DEFAULT_CACHE_READ_DISCOUNT, DEFAULT_FX_RATE, DEFAULT_SNAPSHOT_HISTORY_LIMIT, FX_API_URL, OPENROUTER_MODELS_URL, PricebookHandle, } from "./pricebook.js";
|
|
20
|
+
import { sessionCostProjection } from "./session-cost-projection.js";
|
|
21
|
+
import { collectSubagentCosts } from "./subagent-cost.js";
|
|
22
|
+
export * from "./pricing.js";
|
|
23
|
+
export * from "./pricebook.js";
|
|
24
|
+
export * from "./session-cost-projection.js";
|
|
25
|
+
export * from "./subagent-cost.js";
|
|
26
|
+
/** Stable Cordis plugin name. */
|
|
27
|
+
export const name = 'cost-meter';
|
|
28
|
+
/** Services required before the route and the projection can register. */
|
|
29
|
+
export const inject = ['webServer', 'sessionProjections'];
|
|
30
|
+
/** Public DeepSeek API base; the internal endpoint comes from config `baseURL`. */
|
|
31
|
+
export const PUBLIC_BASE_URL = 'https://api.deepseek.com';
|
|
32
|
+
/** Default credential reference, matching the llm-deepseek adapter. */
|
|
33
|
+
export const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
|
|
34
|
+
/** How long a successful balance snapshot stays fresh (milliseconds). */
|
|
35
|
+
export const DEFAULT_REFRESH_MS = 60_000;
|
|
36
|
+
/** How long a failed refresh stays cached before retrying (milliseconds). */
|
|
37
|
+
const FAILED_REFRESH_MS = 10_000;
|
|
38
|
+
/** Per-request timeout for the provider balance call (milliseconds). */
|
|
39
|
+
const BALANCE_TIMEOUT_MS = 10_000;
|
|
40
|
+
/** Default cadence for re-fetching the official pricing page (hours). */
|
|
41
|
+
export const DEFAULT_PRICING_REFRESH_HOURS = 6;
|
|
42
|
+
/** Max POST body accepted by the settings route (bytes). */
|
|
43
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
44
|
+
const modelPriceSchema = z.object({
|
|
45
|
+
single: z.object({
|
|
46
|
+
cacheReadPerMillion: z.number().min(0),
|
|
47
|
+
inputPerMillion: z.number().min(0),
|
|
48
|
+
outputPerMillion: z.number().min(0),
|
|
49
|
+
}),
|
|
50
|
+
offPeak: z.object({
|
|
51
|
+
cacheReadPerMillion: z.number().min(0),
|
|
52
|
+
inputPerMillion: z.number().min(0),
|
|
53
|
+
outputPerMillion: z.number().min(0),
|
|
54
|
+
}),
|
|
55
|
+
peak: z.object({
|
|
56
|
+
cacheReadPerMillion: z.number().min(0),
|
|
57
|
+
inputPerMillion: z.number().min(0),
|
|
58
|
+
outputPerMillion: z.number().min(0),
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
export const Config = z.object({
|
|
62
|
+
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
|
63
|
+
baseURL: z.string().default(PUBLIC_BASE_URL),
|
|
64
|
+
refreshMs: z.natural().min(1000).default(DEFAULT_REFRESH_MS),
|
|
65
|
+
pricingRefreshHours: z.number().min(1).default(DEFAULT_PRICING_REFRESH_HOURS),
|
|
66
|
+
trustedHosts: z.array(z.string()).default([]),
|
|
67
|
+
fxApiUrl: z.string().default(FX_API_URL),
|
|
68
|
+
defaultFxRate: z.number().min(0.001).default(DEFAULT_FX_RATE),
|
|
69
|
+
openRouterModelsUrl: z.string().default(OPENROUTER_MODELS_URL),
|
|
70
|
+
openRouterEnabled: z.boolean().default(true),
|
|
71
|
+
snapshotHistoryLimit: z.natural().min(1).default(DEFAULT_SNAPSHOT_HISTORY_LIMIT),
|
|
72
|
+
overrides: z.dict(modelPriceSchema, z.string()).default({}),
|
|
73
|
+
aliases: z.dict(z.string(), z.string()).default({ ...DEFAULT_ALIASES }),
|
|
74
|
+
cacheReadDiscount: z.number().min(0).max(1).default(DEFAULT_CACHE_READ_DISCOUNT),
|
|
75
|
+
fxMode: z.union(['auto', 'manual']).default('auto'),
|
|
76
|
+
manualRate: z.number().min(0.001).default(DEFAULT_FX_RATE),
|
|
77
|
+
balanceEnabled: z.boolean().default(true),
|
|
78
|
+
});
|
|
79
|
+
/** Normalized authority of a Host-header value, or undefined when unparsable. */
|
|
80
|
+
function parseAuthority(authority) {
|
|
81
|
+
try {
|
|
82
|
+
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
|
|
83
|
+
return new URL(`http://${authority}`);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether a normalized URL hostname names the local loopback authority.
|
|
91
|
+
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
|
92
|
+
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
|
93
|
+
*/
|
|
94
|
+
export function isLoopbackHostname(hostname) {
|
|
95
|
+
if (hostname === 'localhost' || hostname === '[::1]')
|
|
96
|
+
return true;
|
|
97
|
+
const parts = hostname.split('.');
|
|
98
|
+
return parts.length === 4
|
|
99
|
+
&& parts[0] === '127'
|
|
100
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Browser-trust fence for the balance route, mirroring the /api fence: the
|
|
104
|
+
* Host header is the one header DNS rebinding cannot forge, and loopback or
|
|
105
|
+
* a declared trusted authority passes.
|
|
106
|
+
* @param headers - request headers.
|
|
107
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
108
|
+
* @returns true when the request Host is ours.
|
|
109
|
+
*/
|
|
110
|
+
export function isTrustedRequest(headers, trustedHosts) {
|
|
111
|
+
const host = headers.host;
|
|
112
|
+
if (typeof host !== 'string' || host.length === 0)
|
|
113
|
+
return false;
|
|
114
|
+
const hostUrl = parseAuthority(host);
|
|
115
|
+
if (hostUrl === undefined)
|
|
116
|
+
return false;
|
|
117
|
+
if (isLoopbackHostname(hostUrl.hostname))
|
|
118
|
+
return true;
|
|
119
|
+
return trustedHosts.some((entry) => {
|
|
120
|
+
const entryUrl = parseAuthority(entry);
|
|
121
|
+
return entryUrl !== undefined && entryUrl.host === hostUrl.host;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the API key for one refresh: the credential seam first, the
|
|
126
|
+
* process environment as fallback when no seam is composed.
|
|
127
|
+
* @param ctx - host context.
|
|
128
|
+
* @param env - the credential reference name.
|
|
129
|
+
* @returns the resolved key, or undefined when absent everywhere.
|
|
130
|
+
*/
|
|
131
|
+
export async function resolveApiKey(ctx, env) {
|
|
132
|
+
const credentials = ctx.get('credentials');
|
|
133
|
+
if (credentials !== undefined) {
|
|
134
|
+
const hit = await credentials.resolve(credentialRef(env));
|
|
135
|
+
if (hit !== undefined && hit.value.length > 0)
|
|
136
|
+
return hit.value;
|
|
137
|
+
}
|
|
138
|
+
const ambient = process.env[env];
|
|
139
|
+
return ambient !== undefined && ambient.length > 0 ? ambient : undefined;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse the first balance row, converting the provider's decimal strings to
|
|
143
|
+
* numbers.
|
|
144
|
+
* @param wire - the parsed provider response.
|
|
145
|
+
* @returns the first row, or null when the response carried no rows.
|
|
146
|
+
*/
|
|
147
|
+
export function pickBalanceInfo(wire) {
|
|
148
|
+
const info = wire.balance_infos?.[0];
|
|
149
|
+
if (info === undefined)
|
|
150
|
+
return null;
|
|
151
|
+
return {
|
|
152
|
+
currency: info.currency,
|
|
153
|
+
total: Number(info.total_balance),
|
|
154
|
+
granted: Number(info.granted_balance ?? 0),
|
|
155
|
+
toppedUp: Number(info.topped_up_balance ?? 0),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* One provider balance call: resolve the key, GET /user/balance, and fold
|
|
160
|
+
* the result into a {@link BalanceSnapshot}. Never throws — every failure
|
|
161
|
+
* becomes an ok:false snapshot.
|
|
162
|
+
* @param ctx - host context.
|
|
163
|
+
* @param config - resolved plugin facts.
|
|
164
|
+
* @returns the folded snapshot.
|
|
165
|
+
*/
|
|
166
|
+
export async function fetchBalance(ctx, config) {
|
|
167
|
+
const key = await resolveApiKey(ctx, config.apiKeyEnv);
|
|
168
|
+
if (key === undefined) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
code: 'MISSING_CREDENTIAL',
|
|
172
|
+
message: `cost-meter: no API key for "${config.apiKeyEnv}"; store it through the credentials service or export it in the launching environment`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
let response;
|
|
176
|
+
try {
|
|
177
|
+
response = await fetch(`${config.baseURL}/user/balance`, {
|
|
178
|
+
headers: { authorization: `Bearer ${key}` },
|
|
179
|
+
signal: AbortSignal.timeout(BALANCE_TIMEOUT_MS),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
code: 'NETWORK',
|
|
186
|
+
message: `cost-meter: balance request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
code: `HTTP_${response.status}`,
|
|
193
|
+
message: `cost-meter: balance endpoint answered ${response.status} ${response.statusText}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
let wire;
|
|
197
|
+
try {
|
|
198
|
+
wire = await response.json();
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
code: 'INVALID_RESPONSE',
|
|
204
|
+
message: `cost-meter: balance body unparsable: ${error instanceof Error ? error.message : String(error)}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
isAvailable: wire.is_available === true,
|
|
210
|
+
balance: pickBalanceInfo(wire),
|
|
211
|
+
fetchedAt: Date.now(),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** Read a bounded JSON request body. */
|
|
215
|
+
async function readBody(req) {
|
|
216
|
+
const chunks = [];
|
|
217
|
+
let total = 0;
|
|
218
|
+
for await (const chunk of req) {
|
|
219
|
+
const part = chunk;
|
|
220
|
+
total += part.length;
|
|
221
|
+
if (total > MAX_BODY_BYTES)
|
|
222
|
+
throw new Error('body too large');
|
|
223
|
+
chunks.push(part);
|
|
224
|
+
}
|
|
225
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
226
|
+
if (text.length === 0)
|
|
227
|
+
return {};
|
|
228
|
+
return JSON.parse(text);
|
|
229
|
+
}
|
|
230
|
+
/** The `cost-meter` settings namespace (plugin configuration page). */
|
|
231
|
+
export const SETTINGS_NAMESPACE = settingsNamespace('cost-meter');
|
|
232
|
+
/**
|
|
233
|
+
* Mount the plugin: open the pricebook domain, refresh every remote source,
|
|
234
|
+
* register the `sessionCost` projection, register the /cost-meter
|
|
235
|
+
* route (GET view + POST settings/refresh), and keep the balance and the
|
|
236
|
+
* official pricing fresh.
|
|
237
|
+
* @param ctx - host context.
|
|
238
|
+
* @param config - plugin config (schema defaults applied by the Loader).
|
|
239
|
+
*/
|
|
240
|
+
export async function apply(ctx, config) {
|
|
241
|
+
const resolved = {
|
|
242
|
+
apiKeyEnv: config?.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
|
|
243
|
+
baseURL: config?.baseURL ?? PUBLIC_BASE_URL,
|
|
244
|
+
refreshMs: config?.refreshMs ?? DEFAULT_REFRESH_MS,
|
|
245
|
+
pricingRefreshHours: config?.pricingRefreshHours ?? DEFAULT_PRICING_REFRESH_HOURS,
|
|
246
|
+
trustedHosts: config?.trustedHosts ?? [],
|
|
247
|
+
fxApiUrl: config?.fxApiUrl ?? FX_API_URL,
|
|
248
|
+
defaultFxRate: config?.defaultFxRate ?? DEFAULT_FX_RATE,
|
|
249
|
+
openRouterModelsUrl: config?.openRouterModelsUrl ?? OPENROUTER_MODELS_URL,
|
|
250
|
+
openRouterEnabled: config?.openRouterEnabled ?? true,
|
|
251
|
+
snapshotHistoryLimit: config?.snapshotHistoryLimit ?? DEFAULT_SNAPSHOT_HISTORY_LIMIT,
|
|
252
|
+
};
|
|
253
|
+
const pricebookConfig = {
|
|
254
|
+
fxApiUrl: resolved.fxApiUrl,
|
|
255
|
+
defaultFxRate: resolved.defaultFxRate,
|
|
256
|
+
openRouterModelsUrl: resolved.openRouterModelsUrl,
|
|
257
|
+
openRouterEnabled: resolved.openRouterEnabled,
|
|
258
|
+
snapshotHistoryLimit: resolved.snapshotHistoryLimit,
|
|
259
|
+
};
|
|
260
|
+
// ── Pricebooks: load persisted state, then refresh every remote source. ──
|
|
261
|
+
const pricebookCny = new PricebookHandle(ctx, pricebookConfig, undefined, 'CNY');
|
|
262
|
+
const pricebookUsd = new PricebookHandle(ctx, pricebookConfig, undefined, 'USD');
|
|
263
|
+
await Promise.all([pricebookCny.init(), pricebookUsd.init()]);
|
|
264
|
+
await Promise.all([pricebookCny.refresh(), pricebookUsd.refresh()]);
|
|
265
|
+
ctx.effect(() => () => {
|
|
266
|
+
void pricebookCny.close();
|
|
267
|
+
void pricebookUsd.close();
|
|
268
|
+
}, 'cost-meter: pricebook domains');
|
|
269
|
+
// ── Plugin configuration section: the settings page renders the standard
|
|
270
|
+
// card; every resolved change is applied to both pricebooks and re-anchors.
|
|
271
|
+
let currentSettings = () => config ?? {};
|
|
272
|
+
installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, (config ?? {}), {
|
|
273
|
+
setSource: (source) => {
|
|
274
|
+
currentSettings = source;
|
|
275
|
+
},
|
|
276
|
+
onChange: () => {
|
|
277
|
+
const settings = currentSettings();
|
|
278
|
+
pricebookCny.applySettings(settings);
|
|
279
|
+
pricebookUsd.applySettings(settings);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
// ── sessionCost projections: anchored per-step ledgers for CNY and USD. ──
|
|
283
|
+
ctx.effect(() => ctx.sessionProjections.register(sessionCostProjection(pricebookCny, 'sessionCost')), 'cost-meter: sessionCost projection');
|
|
284
|
+
ctx.effect(() => ctx.sessionProjections.register(sessionCostProjection(pricebookUsd, 'sessionCostUsd')), 'cost-meter: sessionCostUsd projection');
|
|
285
|
+
// ── Balance: cached refresh, one in-flight at a time. ──
|
|
286
|
+
let lastBalance;
|
|
287
|
+
let inflight;
|
|
288
|
+
const refresh = () => {
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
const ttl = lastBalance === undefined
|
|
291
|
+
? 0
|
|
292
|
+
: lastBalance.snapshot.ok ? resolved.refreshMs : FAILED_REFRESH_MS;
|
|
293
|
+
if (lastBalance !== undefined && now - lastBalance.at < ttl) {
|
|
294
|
+
return Promise.resolve(lastBalance.snapshot);
|
|
295
|
+
}
|
|
296
|
+
if (inflight === undefined) {
|
|
297
|
+
inflight = fetchBalance(ctx, resolved).then((snapshot) => {
|
|
298
|
+
lastBalance = { at: Date.now(), snapshot };
|
|
299
|
+
inflight = undefined;
|
|
300
|
+
return snapshot;
|
|
301
|
+
}, (error) => {
|
|
302
|
+
inflight = undefined;
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
code: 'INTERNAL',
|
|
306
|
+
message: `cost-meter: ${error instanceof Error ? error.message : String(error)}`,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return inflight;
|
|
311
|
+
};
|
|
312
|
+
// ── Pricing: refresh on startup and on the configured cadence. ──
|
|
313
|
+
const pricingTimer = setInterval(() => {
|
|
314
|
+
void pricebookCny.refresh();
|
|
315
|
+
void pricebookUsd.refresh();
|
|
316
|
+
}, resolved.pricingRefreshHours * 3_600_000);
|
|
317
|
+
pricingTimer.unref?.();
|
|
318
|
+
ctx.effect(() => () => clearInterval(pricingTimer), 'cost-meter: pricing refresh timer');
|
|
319
|
+
const respond = (res, status, body) => {
|
|
320
|
+
res.writeHead(status, {
|
|
321
|
+
'content-type': 'application/json; charset=utf-8',
|
|
322
|
+
'cache-control': 'no-store',
|
|
323
|
+
});
|
|
324
|
+
res.end(JSON.stringify(body));
|
|
325
|
+
};
|
|
326
|
+
/** Parse the `session` query parameter (the requesting session id). */
|
|
327
|
+
const sessionOf = (req) => {
|
|
328
|
+
try {
|
|
329
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
330
|
+
const value = url.searchParams.get('session');
|
|
331
|
+
return value === null || value.length === 0 ? undefined : value;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
/** Parse the `currency` query parameter; defaults to CNY. */
|
|
338
|
+
const currencyOf = (req) => {
|
|
339
|
+
try {
|
|
340
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
341
|
+
return url.searchParams.get('currency') === 'USD' ? 'USD' : 'CNY';
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return 'CNY';
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const buildResponse = async (rootSessionId, currency) => {
|
|
348
|
+
const agents = ctx.get('agents');
|
|
349
|
+
const sessionsStore = ctx.get('sessions');
|
|
350
|
+
const pricebook = currency === 'USD' ? pricebookUsd : pricebookCny;
|
|
351
|
+
const projectionKey = currency === 'USD' ? 'sessionCostUsd' : 'sessionCost';
|
|
352
|
+
const subagents = rootSessionId === undefined || agents === undefined || sessionsStore === undefined
|
|
353
|
+
? []
|
|
354
|
+
: collectSubagentCosts(rootSessionId, agents, sessionsStore, ctx.sessionProjections, projectionKey);
|
|
355
|
+
return {
|
|
356
|
+
balance: await refresh(),
|
|
357
|
+
pricebook: pricebook.view(),
|
|
358
|
+
subagents,
|
|
359
|
+
};
|
|
360
|
+
};
|
|
361
|
+
const route = {
|
|
362
|
+
kind: 'exact',
|
|
363
|
+
path: '/cost-meter',
|
|
364
|
+
handler: async (req, res) => {
|
|
365
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
366
|
+
res.writeHead(405, { allow: 'GET, POST' });
|
|
367
|
+
res.end('method not allowed');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (!isTrustedRequest(req.headers, resolved.trustedHosts)) {
|
|
371
|
+
res.writeHead(403);
|
|
372
|
+
res.end('forbidden');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (req.method === 'POST') {
|
|
376
|
+
let action;
|
|
377
|
+
try {
|
|
378
|
+
const body = await readBody(req);
|
|
379
|
+
if (body.action !== 'refresh')
|
|
380
|
+
throw new Error(`unknown action "${String(body.action)}"`);
|
|
381
|
+
action = { action: 'refresh' };
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
respond(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await Promise.all([pricebookCny.refresh(), pricebookUsd.refresh()]);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
respond(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
respond(res, 200, await buildResponse(sessionOf(req), currencyOf(req)));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
respond(res, 200, await buildResponse(sessionOf(req), currencyOf(req)));
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
ctx.effect(() => ctx.webServer.register(route), 'cost-meter: /cost-meter route');
|
|
401
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@gamegeek-saikel/dsh-cost-meter`.
|
|
3
|
+
* @module @gamegeek-saikel/dsh-cost-meter/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "cost-meter-invariant";
|
|
8
|
+
/** Service required before the companion can register. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invariant.d.ts","sourceRoot":"","sources":["../../src/invariant.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,oCAAoC;AACpC,eAAO,MAAM,IAAI,yBAAyB,CAAA;AAC1C,0DAA0D;AAC1D,eAAO,MAAM,MAAM,UAAiB,CAAA;AAyBpC;;;;GAIG;AACH,eAAO,MAAM,KAAK,GAAI,KAAK,OAAO,KAAG,OAAO,CAAC,MAAM,IAAI,CACU,CAAA"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@gamegeek-saikel/dsh-cost-meter`.
|
|
3
|
+
* @module @gamegeek-saikel/dsh-cost-meter/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@gamegeek-saikel/dsh-cost-meter';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'cost-meter-invariant';
|
|
8
|
+
/** Service required before the companion can register. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* Route-disposer symmetry: after the owning fiber of the /cost-meter
|
|
12
|
+
* route unloads, the webserver must no longer answer that path. The probe
|
|
13
|
+
* cycle registers and disposes twice on a reserved path — a leftover from
|
|
14
|
+
* the first cycle makes the second register throw the duplicate error,
|
|
15
|
+
* exactly the asymmetry this package would otherwise share with its host.
|
|
16
|
+
*/
|
|
17
|
+
const install = (ctx, fail) => {
|
|
18
|
+
ctx.on('internal/plugin', () => {
|
|
19
|
+
const server = ctx.get('webServer');
|
|
20
|
+
if (server === undefined)
|
|
21
|
+
return; // no webserver row in this composition
|
|
22
|
+
const probe = { kind: 'exact', path: '/__dsh_fare_meter_invariant_probe__', handler: () => { } };
|
|
23
|
+
try {
|
|
24
|
+
server.register(probe)();
|
|
25
|
+
server.register(probe)();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
fail('cost-meter webserver route disposer left a route registered — route tables and fiber lifecycles diverged');
|
|
29
|
+
}
|
|
30
|
+
}, { global: true });
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Register this package's invariant companion.
|
|
34
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
35
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
36
|
+
*/
|
|
37
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
38
|
+
/* jscpd:ignore-end */
|