@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,2403 @@
|
|
|
1
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { canonicalHeader } from "@deepseek-ai/dsh-session";
|
|
6
|
+
//#region node_modules/.pnpm/@deepseek-ai+cosmokit@1.8.2/node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
7
|
+
/** Return true when a value is `null` or `undefined`. */
|
|
8
|
+
function isNullable(value) {
|
|
9
|
+
return value === null || value === void 0;
|
|
10
|
+
}
|
|
11
|
+
/** Return true for non-array object values. */
|
|
12
|
+
function isPlainObject(data) {
|
|
13
|
+
return data && typeof data === "object" && !Array.isArray(data);
|
|
14
|
+
}
|
|
15
|
+
/** Filter object entries and return a new object. */
|
|
16
|
+
function filterKeys(object, filter) {
|
|
17
|
+
return Object.fromEntries(Object.entries(object).filter(([key, value]) => filter(key, value)));
|
|
18
|
+
}
|
|
19
|
+
/** Map object values while preserving the original key set. */
|
|
20
|
+
function mapValues(object, transform) {
|
|
21
|
+
return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, transform(value, key)]));
|
|
22
|
+
}
|
|
23
|
+
/** Pick selected keys from an object, optionally including `undefined` values. */
|
|
24
|
+
function pick(source, keys, forced) {
|
|
25
|
+
if (!keys) return { ...source };
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const key of keys) if (forced || source[key] !== void 0) result[key] = source[key];
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
/** Test values using `instanceof` with a `toStringTag` fallback. */
|
|
31
|
+
function is(type, value) {
|
|
32
|
+
if (arguments.length === 1) return (value) => is(type, value);
|
|
33
|
+
return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
|
|
34
|
+
}
|
|
35
|
+
function isArrayBufferLike(value) {
|
|
36
|
+
return is("ArrayBuffer", value) || is("SharedArrayBuffer", value);
|
|
37
|
+
}
|
|
38
|
+
function isArrayBufferSource(value) {
|
|
39
|
+
return isArrayBufferLike(value) || ArrayBuffer.isView(value);
|
|
40
|
+
}
|
|
41
|
+
/** Binary source detection and base64/hex conversion helpers. */
|
|
42
|
+
var Binary;
|
|
43
|
+
(function(Binary) {
|
|
44
|
+
Binary.is = isArrayBufferLike;
|
|
45
|
+
Binary.isSource = isArrayBufferSource;
|
|
46
|
+
function fromSource(source) {
|
|
47
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
48
|
+
else return source;
|
|
49
|
+
}
|
|
50
|
+
Binary.fromSource = fromSource;
|
|
51
|
+
function toBase64(source) {
|
|
52
|
+
source = fromSource(source);
|
|
53
|
+
if (typeof Buffer !== "undefined") return Buffer.from(source).toString("base64");
|
|
54
|
+
let binary = "";
|
|
55
|
+
const bytes = new Uint8Array(source);
|
|
56
|
+
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
|
57
|
+
return btoa(binary);
|
|
58
|
+
}
|
|
59
|
+
Binary.toBase64 = toBase64;
|
|
60
|
+
function fromBase64(source) {
|
|
61
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64"));
|
|
62
|
+
return Uint8Array.from(atob(source), (c) => c.charCodeAt(0));
|
|
63
|
+
}
|
|
64
|
+
Binary.fromBase64 = fromBase64;
|
|
65
|
+
function toHex(source) {
|
|
66
|
+
source = fromSource(source);
|
|
67
|
+
if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex");
|
|
68
|
+
return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
69
|
+
}
|
|
70
|
+
Binary.toHex = toHex;
|
|
71
|
+
function fromHex(source) {
|
|
72
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex"));
|
|
73
|
+
const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1);
|
|
74
|
+
const buffer = [];
|
|
75
|
+
for (let i = 0; i < hex.length; i += 2) buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16));
|
|
76
|
+
return Uint8Array.from(buffer).buffer;
|
|
77
|
+
}
|
|
78
|
+
Binary.fromHex = fromHex;
|
|
79
|
+
})(Binary || (Binary = {}));
|
|
80
|
+
Binary.fromBase64;
|
|
81
|
+
Binary.toBase64;
|
|
82
|
+
Binary.fromHex;
|
|
83
|
+
Binary.toHex;
|
|
84
|
+
/** Deep-clone common JavaScript values while preserving prototypes and cycles. */
|
|
85
|
+
function clone(source, refs = /* @__PURE__ */ new Map()) {
|
|
86
|
+
if (!source || typeof source !== "object") return source;
|
|
87
|
+
if (is("Date", source)) return new Date(source.valueOf());
|
|
88
|
+
if (is("RegExp", source)) return new RegExp(source.source, source.flags);
|
|
89
|
+
if (isArrayBufferLike(source)) return source.slice(0);
|
|
90
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
91
|
+
const cached = refs.get(source);
|
|
92
|
+
if (cached) return cached;
|
|
93
|
+
if (Array.isArray(source)) {
|
|
94
|
+
const result = [];
|
|
95
|
+
refs.set(source, result);
|
|
96
|
+
source.forEach((value, index) => {
|
|
97
|
+
result[index] = Reflect.apply(clone, null, [value, refs]);
|
|
98
|
+
});
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
const result = Object.create(Object.getPrototypeOf(source));
|
|
102
|
+
refs.set(source, result);
|
|
103
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
104
|
+
const descriptor = { ...Reflect.getOwnPropertyDescriptor(source, key) };
|
|
105
|
+
if ("value" in descriptor) descriptor.value = Reflect.apply(clone, null, [descriptor.value, refs]);
|
|
106
|
+
Reflect.defineProperty(result, key, descriptor);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
/** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */
|
|
111
|
+
function deepEqual(a, b, strict) {
|
|
112
|
+
if (a === b) return true;
|
|
113
|
+
if (!strict && isNullable(a) && isNullable(b)) return true;
|
|
114
|
+
if (typeof a !== typeof b) return false;
|
|
115
|
+
if (typeof a !== "object") return false;
|
|
116
|
+
if (!a || !b) return false;
|
|
117
|
+
function check(test, then) {
|
|
118
|
+
return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
|
|
119
|
+
}
|
|
120
|
+
return check(Array.isArray, (a, b) => a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))) ?? check(is("Date"), (a, b) => a.valueOf() === b.valueOf()) ?? check(is("RegExp"), (a, b) => a.source === b.source && a.flags === b.flags) ?? check(isArrayBufferLike, (a, b) => {
|
|
121
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
122
|
+
const viewA = new Uint8Array(a);
|
|
123
|
+
const viewB = new Uint8Array(b);
|
|
124
|
+
for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
|
|
125
|
+
return true;
|
|
126
|
+
}) ?? Object.keys({
|
|
127
|
+
...a,
|
|
128
|
+
...b
|
|
129
|
+
}).every((key) => deepEqual(a[key], b[key], strict));
|
|
130
|
+
}
|
|
131
|
+
/** Time constants plus parsing and formatting helpers. */
|
|
132
|
+
var Time;
|
|
133
|
+
(function(Time) {
|
|
134
|
+
Time.millisecond = 1;
|
|
135
|
+
Time.second = 1e3;
|
|
136
|
+
Time.minute = Time.second * 60;
|
|
137
|
+
Time.hour = Time.minute * 60;
|
|
138
|
+
Time.day = Time.hour * 24;
|
|
139
|
+
Time.week = Time.day * 7;
|
|
140
|
+
let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
|
|
141
|
+
function setTimezoneOffset(offset) {
|
|
142
|
+
timezoneOffset = offset;
|
|
143
|
+
}
|
|
144
|
+
Time.setTimezoneOffset = setTimezoneOffset;
|
|
145
|
+
function getTimezoneOffset() {
|
|
146
|
+
return timezoneOffset;
|
|
147
|
+
}
|
|
148
|
+
Time.getTimezoneOffset = getTimezoneOffset;
|
|
149
|
+
function getDateNumber(date = /* @__PURE__ */ new Date(), offset) {
|
|
150
|
+
if (typeof date === "number") date = new Date(date);
|
|
151
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
152
|
+
return Math.floor((date.valueOf() / Time.minute - offset) / 1440);
|
|
153
|
+
}
|
|
154
|
+
Time.getDateNumber = getDateNumber;
|
|
155
|
+
function fromDateNumber(value, offset) {
|
|
156
|
+
const date = new Date(value * Time.day);
|
|
157
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
158
|
+
return new Date(+date + offset * Time.minute);
|
|
159
|
+
}
|
|
160
|
+
Time.fromDateNumber = fromDateNumber;
|
|
161
|
+
const numeric = /\d+(?:\.\d+)?/.source;
|
|
162
|
+
const timeRegExp = new RegExp(`^${[
|
|
163
|
+
"w(?:eek(?:s)?)?",
|
|
164
|
+
"d(?:ay(?:s)?)?",
|
|
165
|
+
"h(?:our(?:s)?)?",
|
|
166
|
+
"m(?:in(?:ute)?(?:s)?)?",
|
|
167
|
+
"s(?:ec(?:ond)?(?:s)?)?"
|
|
168
|
+
].map((unit) => `(${numeric}${unit})?`).join("")}$`);
|
|
169
|
+
function parseTime(source) {
|
|
170
|
+
const capture = timeRegExp.exec(source);
|
|
171
|
+
if (!capture) return 0;
|
|
172
|
+
return (parseFloat(capture[1]) * Time.week || 0) + (parseFloat(capture[2]) * Time.day || 0) + (parseFloat(capture[3]) * Time.hour || 0) + (parseFloat(capture[4]) * Time.minute || 0) + (parseFloat(capture[5]) * Time.second || 0);
|
|
173
|
+
}
|
|
174
|
+
Time.parseTime = parseTime;
|
|
175
|
+
function parseDate(date) {
|
|
176
|
+
const parsed = parseTime(date);
|
|
177
|
+
if (parsed) date = Date.now() + parsed;
|
|
178
|
+
else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date}`;
|
|
179
|
+
else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date}`;
|
|
180
|
+
return date ? new Date(date) : /* @__PURE__ */ new Date();
|
|
181
|
+
}
|
|
182
|
+
Time.parseDate = parseDate;
|
|
183
|
+
function format(ms) {
|
|
184
|
+
const abs = Math.abs(ms);
|
|
185
|
+
if (abs >= Time.day - Time.hour / 2) return Math.round(ms / Time.day) + "d";
|
|
186
|
+
else if (abs >= Time.hour - Time.minute / 2) return Math.round(ms / Time.hour) + "h";
|
|
187
|
+
else if (abs >= Time.minute - Time.second / 2) return Math.round(ms / Time.minute) + "m";
|
|
188
|
+
else if (abs >= Time.second) return Math.round(ms / Time.second) + "s";
|
|
189
|
+
return ms + "ms";
|
|
190
|
+
}
|
|
191
|
+
Time.format = format;
|
|
192
|
+
function toDigits(source, length = 2) {
|
|
193
|
+
return source.toString().padStart(length, "0");
|
|
194
|
+
}
|
|
195
|
+
Time.toDigits = toDigits;
|
|
196
|
+
function template(template, time = /* @__PURE__ */ new Date()) {
|
|
197
|
+
return template.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3));
|
|
198
|
+
}
|
|
199
|
+
Time.template = template;
|
|
200
|
+
})(Time || (Time = {}));
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region node_modules/.pnpm/@deepseek-ai+schemastery@3.18.1/node_modules/@deepseek-ai/schemastery/lib/index.mjs
|
|
203
|
+
const kSchema = Symbol.for("schemastery");
|
|
204
|
+
const kValidationError = Symbol.for("ValidationError");
|
|
205
|
+
globalThis.__schemastery_index__ ??= 0;
|
|
206
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
207
|
+
var ValidationError = class extends TypeError {
|
|
208
|
+
options;
|
|
209
|
+
name = "ValidationError";
|
|
210
|
+
constructor(message, options) {
|
|
211
|
+
let prefix = "$";
|
|
212
|
+
for (const segment of options.path || []) if (typeof segment === "string") prefix += "." + segment;
|
|
213
|
+
else if (typeof segment === "number") prefix += "[" + segment + "]";
|
|
214
|
+
else if (typeof segment === "symbol") prefix += `[Symbol(${segment.toString()})]`;
|
|
215
|
+
if (prefix.startsWith(".")) prefix = prefix.slice(1);
|
|
216
|
+
super((prefix === "$" ? "" : `${prefix} `) + message);
|
|
217
|
+
this.options = options;
|
|
218
|
+
}
|
|
219
|
+
static is(error) {
|
|
220
|
+
return !!error?.[kValidationError];
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
Object.defineProperty(ValidationError.prototype, kValidationError, { value: true });
|
|
224
|
+
const Schema = function(options) {
|
|
225
|
+
const schema = function(data, options = {}) {
|
|
226
|
+
return Schema.resolve(data, schema, options)[0];
|
|
227
|
+
};
|
|
228
|
+
if (options.refs) {
|
|
229
|
+
const refs = mapValues(options.refs, (options) => new Schema(options));
|
|
230
|
+
const getRef = (uid) => refs[uid];
|
|
231
|
+
for (const key in refs) {
|
|
232
|
+
const options = refs[key];
|
|
233
|
+
options.sKey = getRef(options.sKey);
|
|
234
|
+
options.inner = getRef(options.inner);
|
|
235
|
+
options.list = options.list && options.list.map(getRef);
|
|
236
|
+
options.dict = options.dict && mapValues(options.dict, getRef);
|
|
237
|
+
}
|
|
238
|
+
return refs[options.uid];
|
|
239
|
+
}
|
|
240
|
+
Object.assign(schema, options);
|
|
241
|
+
if (typeof schema.callback === "string") try {
|
|
242
|
+
schema.callback = new Function("return " + schema.callback)();
|
|
243
|
+
} catch {}
|
|
244
|
+
Object.defineProperty(schema, "uid", { value: globalThis.__schemastery_index__++ });
|
|
245
|
+
Object.setPrototypeOf(schema, Schema.prototype);
|
|
246
|
+
schema.meta ||= {};
|
|
247
|
+
schema.toString = schema.toString.bind(schema);
|
|
248
|
+
return schema;
|
|
249
|
+
};
|
|
250
|
+
Schema.prototype = Object.create(Function.prototype);
|
|
251
|
+
Schema.prototype[kSchema] = true;
|
|
252
|
+
Object.defineProperty(Schema.prototype, "~standard", { get() {
|
|
253
|
+
return {
|
|
254
|
+
version: 1,
|
|
255
|
+
vendor: "schemastery",
|
|
256
|
+
validate: (value) => {
|
|
257
|
+
try {
|
|
258
|
+
return { value: Schema.resolve(value, this, {})[0] };
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (ValidationError.is(error)) return { issues: [{
|
|
261
|
+
message: error.message,
|
|
262
|
+
path: error.options.path
|
|
263
|
+
}] };
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
} });
|
|
269
|
+
Schema.ValidationError = ValidationError;
|
|
270
|
+
Schema.prototype.toJSON = function toJSON() {
|
|
271
|
+
if (globalThis.__schemastery_refs__) {
|
|
272
|
+
globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }));
|
|
273
|
+
return this.uid;
|
|
274
|
+
}
|
|
275
|
+
globalThis.__schemastery_refs__ = { [this.uid]: { ...this } };
|
|
276
|
+
globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }));
|
|
277
|
+
const result = {
|
|
278
|
+
uid: this.uid,
|
|
279
|
+
refs: globalThis.__schemastery_refs__
|
|
280
|
+
};
|
|
281
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
282
|
+
return result;
|
|
283
|
+
};
|
|
284
|
+
Schema.prototype.set = function set(key, value) {
|
|
285
|
+
this.dict[key] = value;
|
|
286
|
+
return this;
|
|
287
|
+
};
|
|
288
|
+
Schema.prototype.push = function push(value) {
|
|
289
|
+
this.list.push(value);
|
|
290
|
+
return this;
|
|
291
|
+
};
|
|
292
|
+
function mergeDesc(original, messages) {
|
|
293
|
+
const result = typeof original === "string" ? { "": original } : { ...original };
|
|
294
|
+
for (const locale in messages) {
|
|
295
|
+
const value = messages[locale];
|
|
296
|
+
if (value?.$description || value?.$desc) result[locale] = value.$description || value.$desc;
|
|
297
|
+
else if (typeof value === "string") result[locale] = value;
|
|
298
|
+
}
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
function getInner(value) {
|
|
302
|
+
return value?.$value ?? value?.$inner;
|
|
303
|
+
}
|
|
304
|
+
function extractKeys(data) {
|
|
305
|
+
return filterKeys(data ?? {}, (key) => !key.startsWith("$"));
|
|
306
|
+
}
|
|
307
|
+
Schema.prototype.i18n = function i18n(messages) {
|
|
308
|
+
const schema = Schema(this);
|
|
309
|
+
const desc = mergeDesc(schema.meta.description, messages);
|
|
310
|
+
if (Object.keys(desc).length) schema.meta.description = desc;
|
|
311
|
+
if (schema.dict) schema.dict = mapValues(schema.dict, (inner, key) => {
|
|
312
|
+
return inner.i18n(mapValues(messages, (data) => getInner(data)?.[key] ?? data?.[key]));
|
|
313
|
+
});
|
|
314
|
+
if (schema.list) schema.list = schema.list.map((inner, index) => {
|
|
315
|
+
return inner.i18n(mapValues(messages, (data = {}) => {
|
|
316
|
+
if (Array.isArray(getInner(data))) return getInner(data)[index];
|
|
317
|
+
if (Array.isArray(data)) return data[index];
|
|
318
|
+
return extractKeys(data);
|
|
319
|
+
}));
|
|
320
|
+
});
|
|
321
|
+
if (schema.inner) schema.inner = schema.inner.i18n(mapValues(messages, (data) => {
|
|
322
|
+
if (getInner(data)) return getInner(data);
|
|
323
|
+
return extractKeys(data);
|
|
324
|
+
}));
|
|
325
|
+
if (schema.sKey) schema.sKey = schema.sKey.i18n(mapValues(messages, (data) => data?.$key));
|
|
326
|
+
return schema;
|
|
327
|
+
};
|
|
328
|
+
Schema.prototype.extra = function extra(key, value) {
|
|
329
|
+
const schema = Schema(this);
|
|
330
|
+
schema.meta = {
|
|
331
|
+
...schema.meta,
|
|
332
|
+
[key]: value
|
|
333
|
+
};
|
|
334
|
+
return schema;
|
|
335
|
+
};
|
|
336
|
+
for (const key of [
|
|
337
|
+
"required",
|
|
338
|
+
"disabled",
|
|
339
|
+
"collapse",
|
|
340
|
+
"hidden",
|
|
341
|
+
"loose"
|
|
342
|
+
]) Object.assign(Schema.prototype, { [key](value = true) {
|
|
343
|
+
const schema = Schema(this);
|
|
344
|
+
schema.meta = {
|
|
345
|
+
...schema.meta,
|
|
346
|
+
[key]: value
|
|
347
|
+
};
|
|
348
|
+
return schema;
|
|
349
|
+
} });
|
|
350
|
+
Schema.prototype.deprecated = function deprecated() {
|
|
351
|
+
const schema = Schema(this);
|
|
352
|
+
schema.meta.badges ||= [];
|
|
353
|
+
schema.meta.badges.push({
|
|
354
|
+
text: "deprecated",
|
|
355
|
+
type: "danger"
|
|
356
|
+
});
|
|
357
|
+
return schema;
|
|
358
|
+
};
|
|
359
|
+
Schema.prototype.experimental = function experimental() {
|
|
360
|
+
const schema = Schema(this);
|
|
361
|
+
schema.meta.badges ||= [];
|
|
362
|
+
schema.meta.badges.push({
|
|
363
|
+
text: "experimental",
|
|
364
|
+
type: "warning"
|
|
365
|
+
});
|
|
366
|
+
return schema;
|
|
367
|
+
};
|
|
368
|
+
Schema.prototype.pattern = function pattern(regexp) {
|
|
369
|
+
const schema = Schema(this);
|
|
370
|
+
const pattern = pick(regexp, ["source", "flags"]);
|
|
371
|
+
schema.meta = {
|
|
372
|
+
...schema.meta,
|
|
373
|
+
pattern
|
|
374
|
+
};
|
|
375
|
+
return schema;
|
|
376
|
+
};
|
|
377
|
+
Schema.prototype.simplify = function simplify(value) {
|
|
378
|
+
if (deepEqual(value, this.meta.default, this.type === "dict")) return null;
|
|
379
|
+
if (isNullable(value)) return value;
|
|
380
|
+
if (this.type === "object" || this.type === "dict") {
|
|
381
|
+
const result = {};
|
|
382
|
+
for (const key in value) {
|
|
383
|
+
const item = (this.type === "object" ? this.dict[key] : this.inner)?.simplify(value[key]);
|
|
384
|
+
if (this.type === "dict" || !isNullable(item)) result[key] = item;
|
|
385
|
+
}
|
|
386
|
+
if (deepEqual(result, this.meta.default, this.type === "dict")) return null;
|
|
387
|
+
return result;
|
|
388
|
+
} else if (this.type === "array" || this.type === "tuple") {
|
|
389
|
+
const result = [];
|
|
390
|
+
value.forEach((value, index) => {
|
|
391
|
+
const schema = this.type === "array" ? this.inner : this.list[index];
|
|
392
|
+
const item = schema ? schema.simplify(value) : value;
|
|
393
|
+
result.push(item);
|
|
394
|
+
});
|
|
395
|
+
return result;
|
|
396
|
+
} else if (this.type === "intersect") {
|
|
397
|
+
const result = {};
|
|
398
|
+
for (const item of this.list) Object.assign(result, item.simplify(value));
|
|
399
|
+
return result;
|
|
400
|
+
} else if (this.type === "union") for (const schema of this.list) try {
|
|
401
|
+
Schema.resolve(value, schema, {});
|
|
402
|
+
return schema.simplify(value);
|
|
403
|
+
} catch {}
|
|
404
|
+
return value;
|
|
405
|
+
};
|
|
406
|
+
Schema.prototype.toString = function toString(inline) {
|
|
407
|
+
return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`;
|
|
408
|
+
};
|
|
409
|
+
Schema.prototype.role = function role(role, extra) {
|
|
410
|
+
const schema = Schema(this);
|
|
411
|
+
schema.meta = {
|
|
412
|
+
...schema.meta,
|
|
413
|
+
role,
|
|
414
|
+
extra
|
|
415
|
+
};
|
|
416
|
+
return schema;
|
|
417
|
+
};
|
|
418
|
+
for (const key of [
|
|
419
|
+
"default",
|
|
420
|
+
"link",
|
|
421
|
+
"comment",
|
|
422
|
+
"description",
|
|
423
|
+
"max",
|
|
424
|
+
"min",
|
|
425
|
+
"step"
|
|
426
|
+
]) Object.assign(Schema.prototype, { [key](value) {
|
|
427
|
+
const schema = Schema(this);
|
|
428
|
+
schema.meta = {
|
|
429
|
+
...schema.meta,
|
|
430
|
+
[key]: value
|
|
431
|
+
};
|
|
432
|
+
return schema;
|
|
433
|
+
} });
|
|
434
|
+
const resolvers = {};
|
|
435
|
+
Schema.extend = function extend(type, resolve) {
|
|
436
|
+
resolvers[type] = resolve;
|
|
437
|
+
};
|
|
438
|
+
Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
|
|
439
|
+
if (!schema) return [data];
|
|
440
|
+
if (options.ignore?.(data, schema)) return [data];
|
|
441
|
+
if (isNullable(data) && schema.type !== "lazy") {
|
|
442
|
+
if (schema.meta.required) throw new ValidationError(`missing required value`, options);
|
|
443
|
+
let current = schema;
|
|
444
|
+
let fallback = schema.meta.default;
|
|
445
|
+
while (current?.type === "intersect" && isNullable(fallback)) {
|
|
446
|
+
current = current.list[0];
|
|
447
|
+
fallback = current?.meta.default;
|
|
448
|
+
}
|
|
449
|
+
if (isNullable(fallback)) return [data];
|
|
450
|
+
data = clone(fallback);
|
|
451
|
+
}
|
|
452
|
+
const callback = resolvers[schema.type];
|
|
453
|
+
if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options);
|
|
454
|
+
try {
|
|
455
|
+
return callback(data, schema, options, strict);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (!schema.meta.loose) throw error;
|
|
458
|
+
return [schema.meta.default];
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
Schema.from = function from(source) {
|
|
462
|
+
if (isNullable(source)) return Schema.any();
|
|
463
|
+
else if ([
|
|
464
|
+
"string",
|
|
465
|
+
"number",
|
|
466
|
+
"boolean"
|
|
467
|
+
].includes(typeof source)) return Schema.const(source).required();
|
|
468
|
+
else if (source[kSchema]) return source;
|
|
469
|
+
else if (typeof source === "function") switch (source) {
|
|
470
|
+
case String: return Schema.string().required();
|
|
471
|
+
case Number: return Schema.number().required();
|
|
472
|
+
case Boolean: return Schema.boolean().required();
|
|
473
|
+
case Function: return Schema.function().required();
|
|
474
|
+
default: return Schema.is(source).required();
|
|
475
|
+
}
|
|
476
|
+
else throw new TypeError(`cannot infer schema from ${source}`);
|
|
477
|
+
};
|
|
478
|
+
Schema.lazy = function lazy(builder) {
|
|
479
|
+
const toJSON = () => {
|
|
480
|
+
if (!schema.inner[kSchema]) {
|
|
481
|
+
schema.inner = schema.builder();
|
|
482
|
+
schema.inner.meta = {
|
|
483
|
+
...schema.meta,
|
|
484
|
+
...schema.inner.meta
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return schema.inner.toJSON();
|
|
488
|
+
};
|
|
489
|
+
const schema = new Schema({
|
|
490
|
+
type: "lazy",
|
|
491
|
+
builder,
|
|
492
|
+
inner: { toJSON }
|
|
493
|
+
});
|
|
494
|
+
return schema;
|
|
495
|
+
};
|
|
496
|
+
Schema.natural = function natural() {
|
|
497
|
+
return Schema.number().step(1).min(0);
|
|
498
|
+
};
|
|
499
|
+
Schema.percent = function percent() {
|
|
500
|
+
return Schema.number().step(.01).min(0).max(1).role("slider");
|
|
501
|
+
};
|
|
502
|
+
Schema.date = function date() {
|
|
503
|
+
return Schema.union([Schema.is(Date), Schema.transform(Schema.string().role("datetime"), (value, options) => {
|
|
504
|
+
const date = new Date(value);
|
|
505
|
+
if (isNaN(+date)) throw new ValidationError(`invalid date "${value}"`, options);
|
|
506
|
+
return date;
|
|
507
|
+
}, true)]);
|
|
508
|
+
};
|
|
509
|
+
Schema.regExp = function regExp(flag = "") {
|
|
510
|
+
return Schema.union([Schema.is(RegExp), Schema.transform(Schema.string().role("regexp", { flag }), (value, options) => {
|
|
511
|
+
try {
|
|
512
|
+
return new RegExp(value, flag);
|
|
513
|
+
} catch (e) {
|
|
514
|
+
throw new ValidationError(e.message, options);
|
|
515
|
+
}
|
|
516
|
+
}, true)]);
|
|
517
|
+
};
|
|
518
|
+
Schema.arrayBuffer = function arrayBuffer(encoding) {
|
|
519
|
+
return Schema.union([
|
|
520
|
+
Schema.is(ArrayBuffer),
|
|
521
|
+
Schema.is(SharedArrayBuffer),
|
|
522
|
+
Schema.transform(Schema.any(), (value, options) => {
|
|
523
|
+
if (Binary.isSource(value)) return Binary.fromSource(value);
|
|
524
|
+
throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options);
|
|
525
|
+
}, true),
|
|
526
|
+
...encoding ? [Schema.transform(Schema.string(), (value, options) => {
|
|
527
|
+
try {
|
|
528
|
+
return encoding === "base64" ? Binary.fromBase64(value) : Binary.fromHex(value);
|
|
529
|
+
} catch (e) {
|
|
530
|
+
throw new ValidationError(e.message, options);
|
|
531
|
+
}
|
|
532
|
+
}, true)] : []
|
|
533
|
+
]);
|
|
534
|
+
};
|
|
535
|
+
Schema.extend("lazy", (data, schema, options, strict) => {
|
|
536
|
+
if (!schema.inner[kSchema]) {
|
|
537
|
+
schema.inner = schema.builder();
|
|
538
|
+
schema.inner.meta = {
|
|
539
|
+
...schema.meta,
|
|
540
|
+
...schema.inner.meta
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return Schema.resolve(data, schema.inner, options, strict);
|
|
544
|
+
});
|
|
545
|
+
Schema.extend("any", (data) => {
|
|
546
|
+
return [data];
|
|
547
|
+
});
|
|
548
|
+
Schema.extend("never", (data, _, options) => {
|
|
549
|
+
throw new ValidationError(`expected nullable but got ${data}`, options);
|
|
550
|
+
});
|
|
551
|
+
Schema.extend("const", (data, { value }, options) => {
|
|
552
|
+
if (deepEqual(data, value)) return [value];
|
|
553
|
+
throw new ValidationError(`expected ${value} but got ${data}`, options);
|
|
554
|
+
});
|
|
555
|
+
function checkWithinRange(data, meta, description, options, skipMin = false) {
|
|
556
|
+
const { max = Infinity, min = -Infinity } = meta;
|
|
557
|
+
if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options);
|
|
558
|
+
if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options);
|
|
559
|
+
}
|
|
560
|
+
Schema.extend("string", (data, { meta }, options) => {
|
|
561
|
+
if (typeof data !== "string") throw new ValidationError(`expected string but got ${data}`, options);
|
|
562
|
+
if (meta.pattern) {
|
|
563
|
+
const regexp = new RegExp(meta.pattern.source, meta.pattern.flags);
|
|
564
|
+
if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options);
|
|
565
|
+
}
|
|
566
|
+
checkWithinRange(data.length, meta, "string length", options);
|
|
567
|
+
return [data];
|
|
568
|
+
});
|
|
569
|
+
function decimalShift(data, digits) {
|
|
570
|
+
const str = data.toString();
|
|
571
|
+
if (str.includes("e")) return data * Math.pow(10, digits);
|
|
572
|
+
const index = str.indexOf(".");
|
|
573
|
+
if (index === -1) return data * Math.pow(10, digits);
|
|
574
|
+
const frac = str.slice(index + 1);
|
|
575
|
+
const integer = str.slice(0, index);
|
|
576
|
+
if (frac.length <= digits) return +(integer + frac.padEnd(digits, "0"));
|
|
577
|
+
return +(integer + frac.slice(0, digits) + "." + frac.slice(digits));
|
|
578
|
+
}
|
|
579
|
+
function isMultipleOf(data, min, step) {
|
|
580
|
+
step = Math.abs(step);
|
|
581
|
+
if (!/^\d+\.\d+$/.test(step.toString())) return (data - min) % step === 0;
|
|
582
|
+
const index = step.toString().indexOf(".");
|
|
583
|
+
const digits = step.toString().slice(index + 1).length;
|
|
584
|
+
return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0;
|
|
585
|
+
}
|
|
586
|
+
Schema.extend("number", (data, { meta }, options) => {
|
|
587
|
+
if (typeof data !== "number") throw new ValidationError(`expected number but got ${data}`, options);
|
|
588
|
+
checkWithinRange(data, meta, "number", options);
|
|
589
|
+
const { step } = meta;
|
|
590
|
+
if (step && !isMultipleOf(data, meta.min ?? 0, step)) throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options);
|
|
591
|
+
return [data];
|
|
592
|
+
});
|
|
593
|
+
Schema.extend("boolean", (data, _, options) => {
|
|
594
|
+
if (typeof data === "boolean") return [data];
|
|
595
|
+
throw new ValidationError(`expected boolean but got ${data}`, options);
|
|
596
|
+
});
|
|
597
|
+
Schema.extend("bitset", (data, { bits, meta }, options) => {
|
|
598
|
+
let value = 0, keys = [];
|
|
599
|
+
if (typeof data === "number") {
|
|
600
|
+
value = data;
|
|
601
|
+
for (const key in bits) if (data & bits[key]) keys.push(key);
|
|
602
|
+
} else if (Array.isArray(data)) {
|
|
603
|
+
keys = data;
|
|
604
|
+
for (const key of keys) {
|
|
605
|
+
if (typeof key !== "string") throw new ValidationError(`expected string but got ${key}`, options);
|
|
606
|
+
if (key in bits) value |= bits[key];
|
|
607
|
+
}
|
|
608
|
+
} else throw new ValidationError(`expected number or array but got ${data}`, options);
|
|
609
|
+
if (value === meta.default) return [value];
|
|
610
|
+
return [value, keys];
|
|
611
|
+
});
|
|
612
|
+
Schema.extend("function", (data, _, options) => {
|
|
613
|
+
if (typeof data === "function") return [data];
|
|
614
|
+
throw new ValidationError(`expected function but got ${data}`, options);
|
|
615
|
+
});
|
|
616
|
+
Schema.extend("is", (data, { constructor }, options) => {
|
|
617
|
+
if (typeof constructor === "function") {
|
|
618
|
+
if (data instanceof constructor) return [data];
|
|
619
|
+
throw new ValidationError(`expected ${constructor.name} but got ${data}`, options);
|
|
620
|
+
} else {
|
|
621
|
+
if (isNullable(data)) throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
622
|
+
let prototype = Object.getPrototypeOf(data);
|
|
623
|
+
while (prototype) {
|
|
624
|
+
if (prototype.constructor?.name === constructor) return [data];
|
|
625
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
626
|
+
}
|
|
627
|
+
throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
function property(data, key, schema, options) {
|
|
631
|
+
try {
|
|
632
|
+
const [value, adapted] = Schema.resolve(data[key], schema, {
|
|
633
|
+
...options,
|
|
634
|
+
path: [...options.path || [], key]
|
|
635
|
+
});
|
|
636
|
+
if (adapted !== void 0) data[key] = adapted;
|
|
637
|
+
return value;
|
|
638
|
+
} catch (e) {
|
|
639
|
+
if (!options?.autofix) throw e;
|
|
640
|
+
delete data[key];
|
|
641
|
+
return schema.meta.default;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
Schema.extend("array", (data, { inner, meta }, options) => {
|
|
645
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
646
|
+
checkWithinRange(data.length, meta, "array length", options, !isNullable(inner.meta.default));
|
|
647
|
+
return [data.map((_, index) => property(data, index, inner, options))];
|
|
648
|
+
});
|
|
649
|
+
Schema.extend("dict", (data, { inner, sKey }, options, strict) => {
|
|
650
|
+
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
651
|
+
const result = {};
|
|
652
|
+
for (const key in data) {
|
|
653
|
+
let rKey;
|
|
654
|
+
try {
|
|
655
|
+
rKey = Schema.resolve(key, sKey, options)[0];
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (strict) continue;
|
|
658
|
+
throw error;
|
|
659
|
+
}
|
|
660
|
+
result[rKey] = property(data, key, inner, options);
|
|
661
|
+
data[rKey] = data[key];
|
|
662
|
+
if (key !== rKey) delete data[key];
|
|
663
|
+
}
|
|
664
|
+
return [result];
|
|
665
|
+
});
|
|
666
|
+
Schema.extend("tuple", (data, { list }, options, strict) => {
|
|
667
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
668
|
+
const result = list.map((inner, index) => property(data, index, inner, options));
|
|
669
|
+
if (strict) return [result];
|
|
670
|
+
result.push(...data.slice(list.length));
|
|
671
|
+
return [result];
|
|
672
|
+
});
|
|
673
|
+
function merge(result, data) {
|
|
674
|
+
for (const key in data) {
|
|
675
|
+
if (key in result) continue;
|
|
676
|
+
result[key] = data[key];
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
Schema.extend("object", (data, { dict }, options, strict) => {
|
|
680
|
+
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
681
|
+
const result = {};
|
|
682
|
+
for (const key in dict) {
|
|
683
|
+
const value = property(data, key, dict[key], options);
|
|
684
|
+
if (!isNullable(value) || key in data) result[key] = value;
|
|
685
|
+
}
|
|
686
|
+
if (!strict) merge(result, data);
|
|
687
|
+
return [result];
|
|
688
|
+
});
|
|
689
|
+
Schema.extend("union", (data, { list, toString }, options, strict) => {
|
|
690
|
+
const messages = [];
|
|
691
|
+
for (const inner of list) try {
|
|
692
|
+
return Schema.resolve(data, inner, options, strict);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
messages.push(error);
|
|
695
|
+
}
|
|
696
|
+
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options);
|
|
697
|
+
});
|
|
698
|
+
Schema.extend("intersect", (data, { list, toString }, options, strict) => {
|
|
699
|
+
if (!list.length) return [data];
|
|
700
|
+
let result;
|
|
701
|
+
for (const inner of list) {
|
|
702
|
+
const value = Schema.resolve(data, inner, options, true)[0];
|
|
703
|
+
if (isNullable(value)) continue;
|
|
704
|
+
if (isNullable(result)) result = value;
|
|
705
|
+
else if (typeof result !== typeof value) throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options);
|
|
706
|
+
else if (typeof value === "object") merge(result ??= {}, value);
|
|
707
|
+
else if (result !== value) throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options);
|
|
708
|
+
}
|
|
709
|
+
if (!strict && isPlainObject(data)) merge(result, data);
|
|
710
|
+
return [result];
|
|
711
|
+
});
|
|
712
|
+
Schema.extend("transform", (data, { inner, callback, preserve }, options) => {
|
|
713
|
+
const [result, adapted = data] = Schema.resolve(data, inner, options, true);
|
|
714
|
+
if (preserve) return [callback(result)];
|
|
715
|
+
else return [callback(result), callback(adapted)];
|
|
716
|
+
});
|
|
717
|
+
const formatters = {};
|
|
718
|
+
function defineMethod(name, keys, format) {
|
|
719
|
+
formatters[name] = format;
|
|
720
|
+
Object.assign(Schema, { [name](...args) {
|
|
721
|
+
const schema = new Schema({ type: name });
|
|
722
|
+
keys.forEach((key, index) => {
|
|
723
|
+
switch (key) {
|
|
724
|
+
case "sKey":
|
|
725
|
+
schema.sKey = args[index] ?? Schema.string();
|
|
726
|
+
break;
|
|
727
|
+
case "inner":
|
|
728
|
+
schema.inner = Schema.from(args[index]);
|
|
729
|
+
break;
|
|
730
|
+
case "list":
|
|
731
|
+
schema.list = args[index].map(Schema.from);
|
|
732
|
+
break;
|
|
733
|
+
case "dict":
|
|
734
|
+
schema.dict = mapValues(args[index], Schema.from);
|
|
735
|
+
break;
|
|
736
|
+
case "bits":
|
|
737
|
+
schema.bits = {};
|
|
738
|
+
for (const key in args[index]) {
|
|
739
|
+
if (typeof args[index][key] !== "number") continue;
|
|
740
|
+
schema.bits[key] = args[index][key];
|
|
741
|
+
}
|
|
742
|
+
break;
|
|
743
|
+
case "callback": {
|
|
744
|
+
const callback = schema.callback = args[index];
|
|
745
|
+
callback["toJSON"] ||= () => callback.toString();
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
case "constructor": {
|
|
749
|
+
const constructor = schema.constructor = args[index];
|
|
750
|
+
if (typeof constructor === "function") constructor["toJSON"] ||= () => constructor["name"];
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
default: schema[key] = args[index];
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
if (name === "object" || name === "dict") schema.meta.default = {};
|
|
757
|
+
else if (name === "array" || name === "tuple") schema.meta.default = [];
|
|
758
|
+
else if (name === "bitset") schema.meta.default = 0;
|
|
759
|
+
return schema;
|
|
760
|
+
} });
|
|
761
|
+
}
|
|
762
|
+
defineMethod("is", ["constructor"], ({ constructor }) => {
|
|
763
|
+
if (typeof constructor === "function") return constructor.name;
|
|
764
|
+
else return constructor;
|
|
765
|
+
});
|
|
766
|
+
defineMethod("any", [], () => "any");
|
|
767
|
+
defineMethod("never", [], () => "never");
|
|
768
|
+
defineMethod("const", ["value"], ({ value }) => typeof value === "string" ? JSON.stringify(value) : value);
|
|
769
|
+
defineMethod("string", [], () => "string");
|
|
770
|
+
defineMethod("number", [], () => "number");
|
|
771
|
+
defineMethod("boolean", [], () => "boolean");
|
|
772
|
+
defineMethod("bitset", ["bits"], () => "bitset");
|
|
773
|
+
defineMethod("function", [], () => "function");
|
|
774
|
+
defineMethod("array", ["inner"], ({ inner }) => `${inner.toString(true)}[]`);
|
|
775
|
+
defineMethod("dict", ["inner", "sKey"], ({ inner, sKey }) => `{ [key: ${sKey.toString()}]: ${inner.toString()} }`);
|
|
776
|
+
defineMethod("tuple", ["list"], ({ list }) => `[${list.map((inner) => inner.toString()).join(", ")}]`);
|
|
777
|
+
defineMethod("object", ["dict"], ({ dict }) => {
|
|
778
|
+
if (Object.keys(dict).length === 0) return "{}";
|
|
779
|
+
return `{ ${Object.entries(dict).map(([key, inner]) => {
|
|
780
|
+
return `${key}${inner.meta.required ? "" : "?"}: ${inner.toString()}`;
|
|
781
|
+
}).join(", ")} }`;
|
|
782
|
+
});
|
|
783
|
+
defineMethod("union", ["list"], ({ list }, inline) => {
|
|
784
|
+
const result = list.map(({ toString: format }) => format()).join(" | ");
|
|
785
|
+
return inline ? `(${result})` : result;
|
|
786
|
+
});
|
|
787
|
+
defineMethod("intersect", ["list"], ({ list }) => {
|
|
788
|
+
return `${list.map((inner) => inner.toString(true)).join(" & ")}`;
|
|
789
|
+
});
|
|
790
|
+
defineMethod("transform", [
|
|
791
|
+
"inner",
|
|
792
|
+
"callback",
|
|
793
|
+
"preserve"
|
|
794
|
+
], ({ inner }, isInner) => inner.toString(isInner));
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/pricing.ts
|
|
797
|
+
/** Official pricing page URL (zh-cn). */
|
|
798
|
+
const PRICING_URL = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/";
|
|
799
|
+
/** Official pricing page URL (en). */
|
|
800
|
+
const PRICING_URL_EN = "https://api-docs.deepseek.com/quick_start/pricing/";
|
|
801
|
+
/** Official peak-pricing rollout: 2026-08-17 00:00 Beijing time (UTC+8). */
|
|
802
|
+
const PEAK_PRICING_START_MS = Date.UTC(2026, 7, 16, 16, 0, 0);
|
|
803
|
+
/**
|
|
804
|
+
* Built-in fallback prices: the official list for deepseek-v4-flash and
|
|
805
|
+
* deepseek-v4-pro (CNY per 1M tokens). DeepSeek bills cache writes at the
|
|
806
|
+
* uncached input price (its wire usage reports only cache-hit vs cache-miss
|
|
807
|
+
* buckets), so no separate cache-write rate exists.
|
|
808
|
+
*/
|
|
809
|
+
const FALLBACK_CURRENT = {
|
|
810
|
+
flash: {
|
|
811
|
+
cacheReadPerMillion: .02,
|
|
812
|
+
inputPerMillion: 1,
|
|
813
|
+
outputPerMillion: 2
|
|
814
|
+
},
|
|
815
|
+
pro: {
|
|
816
|
+
cacheReadPerMillion: .025,
|
|
817
|
+
inputPerMillion: 3,
|
|
818
|
+
outputPerMillion: 6
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
/**
|
|
822
|
+
* Built-in fallback for the upcoming peak table: the 2026-08-17 schedule
|
|
823
|
+
* (peak hours double the off-peak half).
|
|
824
|
+
*/
|
|
825
|
+
const FALLBACK_PEAK = {
|
|
826
|
+
flash: {
|
|
827
|
+
offPeak: {
|
|
828
|
+
cacheReadPerMillion: .05,
|
|
829
|
+
inputPerMillion: 1.5,
|
|
830
|
+
outputPerMillion: 4.5
|
|
831
|
+
},
|
|
832
|
+
peak: {
|
|
833
|
+
cacheReadPerMillion: .1,
|
|
834
|
+
inputPerMillion: 3,
|
|
835
|
+
outputPerMillion: 9
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
pro: {
|
|
839
|
+
offPeak: {
|
|
840
|
+
cacheReadPerMillion: .15,
|
|
841
|
+
inputPerMillion: 4.5,
|
|
842
|
+
outputPerMillion: 13.5
|
|
843
|
+
},
|
|
844
|
+
peak: {
|
|
845
|
+
cacheReadPerMillion: .3,
|
|
846
|
+
inputPerMillion: 9,
|
|
847
|
+
outputPerMillion: 27
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
/** Built-in fallback USD prices from the English pricing page. */
|
|
852
|
+
const FALLBACK_CURRENT_USD = {
|
|
853
|
+
flash: {
|
|
854
|
+
cacheReadPerMillion: .007,
|
|
855
|
+
inputPerMillion: .22,
|
|
856
|
+
outputPerMillion: .66
|
|
857
|
+
},
|
|
858
|
+
pro: {
|
|
859
|
+
cacheReadPerMillion: .022,
|
|
860
|
+
inputPerMillion: .66,
|
|
861
|
+
outputPerMillion: 1.98
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
/** Built-in fallback USD peak table from the English pricing page. */
|
|
865
|
+
const FALLBACK_PEAK_USD = {
|
|
866
|
+
flash: {
|
|
867
|
+
offPeak: { ...FALLBACK_CURRENT_USD.flash },
|
|
868
|
+
peak: {
|
|
869
|
+
cacheReadPerMillion: .014,
|
|
870
|
+
inputPerMillion: .44,
|
|
871
|
+
outputPerMillion: 1.32
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
pro: {
|
|
875
|
+
offPeak: { ...FALLBACK_CURRENT_USD.pro },
|
|
876
|
+
peak: {
|
|
877
|
+
cacheReadPerMillion: .044,
|
|
878
|
+
inputPerMillion: 1.32,
|
|
879
|
+
outputPerMillion: 3.96
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
/** Number regex: `0.02`, `1`, `2`, `3.0` etc. Deliberately non-global: a
|
|
884
|
+
* shared global regex leaks `lastIndex` across `exec` calls and would skip
|
|
885
|
+
* the second price cell (the pro price). */
|
|
886
|
+
const PRICE_RE = /(\d+(?:\.\d+)?)\s*元/;
|
|
887
|
+
/** Strip HTML tags to plain text (keeps cell order). */
|
|
888
|
+
function stripHtml(html) {
|
|
889
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/\s+/g, " ").trim();
|
|
890
|
+
}
|
|
891
|
+
/** Parse one price cell text like `0.02元` into a number; undefined when absent. */
|
|
892
|
+
function parsePriceCell(text) {
|
|
893
|
+
const match = PRICE_RE.exec(text);
|
|
894
|
+
if (match === null) return void 0;
|
|
895
|
+
const value = Number(match[1]);
|
|
896
|
+
return Number.isFinite(value) ? value : void 0;
|
|
897
|
+
}
|
|
898
|
+
function secondPriceCell(text) {
|
|
899
|
+
return text.replace(/^\s*(\d+(?:\.\d+)?元)/, "");
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Parse the current single-price table: three rows labeled with the bucket
|
|
903
|
+
* names, each carrying the flash and pro price cells.
|
|
904
|
+
* @param html - the raw pricing page.
|
|
905
|
+
* @returns the parsed table, or undefined when the labels are absent.
|
|
906
|
+
*/
|
|
907
|
+
function parseCurrentTable(html) {
|
|
908
|
+
const hit = /百万tokens输入(缓存命中)([\s\S]{0,400}?)百万tokens输入(缓存未命中)([\s\S]{0,400}?)百万tokens输出([\s\S]{0,400}?)(?:并发限制|<\/table)/i.exec(stripHtml(html));
|
|
909
|
+
if (hit === null) return void 0;
|
|
910
|
+
const cacheReadCell = hit[1] ?? "";
|
|
911
|
+
const inputCell = hit[2] ?? "";
|
|
912
|
+
const outputCell = hit[3] ?? "";
|
|
913
|
+
const cacheReadFlash = parsePriceCell(cacheReadCell);
|
|
914
|
+
const cacheReadPro = parsePriceCell(secondPriceCell(cacheReadCell));
|
|
915
|
+
const inputFlash = parsePriceCell(inputCell);
|
|
916
|
+
const inputPro = parsePriceCell(secondPriceCell(inputCell));
|
|
917
|
+
const outputFlash = parsePriceCell(outputCell);
|
|
918
|
+
const outputPro = parsePriceCell(secondPriceCell(outputCell));
|
|
919
|
+
if (cacheReadFlash === void 0 || inputFlash === void 0 || outputFlash === void 0) return;
|
|
920
|
+
return {
|
|
921
|
+
flash: {
|
|
922
|
+
cacheReadPerMillion: cacheReadFlash,
|
|
923
|
+
inputPerMillion: inputFlash,
|
|
924
|
+
outputPerMillion: outputFlash
|
|
925
|
+
},
|
|
926
|
+
pro: {
|
|
927
|
+
cacheReadPerMillion: cacheReadPro ?? cacheReadFlash,
|
|
928
|
+
inputPerMillion: inputPro ?? inputFlash,
|
|
929
|
+
outputPerMillion: outputPro ?? outputFlash
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Parse the upcoming peak-pricing table: model rows with off-peak and peak
|
|
935
|
+
* cells, e.g. `deepseek-v4-flash 空闲时段 0.05 1.5 4.5 高峰时段 0.10 3.0 9.0`.
|
|
936
|
+
* @param html - the raw pricing page.
|
|
937
|
+
* @returns the parsed table, or undefined when either model row is absent.
|
|
938
|
+
*/
|
|
939
|
+
function parsePeakTable(html) {
|
|
940
|
+
const text = stripHtml(html);
|
|
941
|
+
const flash = /deepseek-v4-flash\s+空闲时段\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元\s+高峰时段\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元/i.exec(text);
|
|
942
|
+
const pro = /deepseek-v4-pro\s+空闲时段\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元\s+高峰时段\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元\s+(\d+(?:\.\d+)?)元/i.exec(text);
|
|
943
|
+
if (flash === null || pro === null) return void 0;
|
|
944
|
+
return {
|
|
945
|
+
flash: {
|
|
946
|
+
offPeak: {
|
|
947
|
+
cacheReadPerMillion: Number(flash[1]),
|
|
948
|
+
inputPerMillion: Number(flash[2]),
|
|
949
|
+
outputPerMillion: Number(flash[3])
|
|
950
|
+
},
|
|
951
|
+
peak: {
|
|
952
|
+
cacheReadPerMillion: Number(flash[4]),
|
|
953
|
+
inputPerMillion: Number(flash[5]),
|
|
954
|
+
outputPerMillion: Number(flash[6])
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
pro: {
|
|
958
|
+
offPeak: {
|
|
959
|
+
cacheReadPerMillion: Number(pro[1]),
|
|
960
|
+
inputPerMillion: Number(pro[2]),
|
|
961
|
+
outputPerMillion: Number(pro[3])
|
|
962
|
+
},
|
|
963
|
+
peak: {
|
|
964
|
+
cacheReadPerMillion: Number(pro[4]),
|
|
965
|
+
inputPerMillion: Number(pro[5]),
|
|
966
|
+
outputPerMillion: Number(pro[6])
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Parse the English current single-price table.
|
|
973
|
+
* @param html - the raw English pricing page.
|
|
974
|
+
* @returns the parsed table, or undefined when the labels are absent.
|
|
975
|
+
*/
|
|
976
|
+
/**
|
|
977
|
+
* Parse the English combined pricing table. The English page lists all three
|
|
978
|
+
* buckets as rows with OFF-PEAK and PEAK cells, so we extract both the current
|
|
979
|
+
* off-peak list and the peak table from the same markup.
|
|
980
|
+
*/
|
|
981
|
+
function parseEnglishPricing(html) {
|
|
982
|
+
const text = stripHtml(html);
|
|
983
|
+
const hit = /1M INPUT TOKENS \(CACHE HIT\)\s+OFF-PEAK\s+\$?\s*(\d+(?:\.\d+)?)\s+\$?\s*(\d+(?:\.\d+)?)\s+PEAK\s+\$?\s*(\d+(?:\.\d+)?)\s+\$?\s*(\d+(?:\.\d+)?)\s+1M INPUT TOKENS \(CACHE MISS\)\s+OFF-PEAK\s+\$?\s*(\d+(?:\.\d+)?)\s+\$?\s*(\d+(?:\.\d+)?)\s+PEAK\s+\$?\s*(\d+(?:\.\d+)?)\s+\$?\s*(\d+(?:\.\d+)?)\s+1M OUTPUT TOKENS\s+OFF-PEAK\s+\$?\s*(\d+(?:\.\d+)?)\s+\$?\s*(\d+(?:\.\d+)?)\s+PEAK\s+\$?\s*(\d+(?:\.\d+)?)\s+\$?\s*(\d+(?:\.\d+)?)/i.exec(text);
|
|
984
|
+
if (hit === null) return void 0;
|
|
985
|
+
const n = (index) => Number(hit[index]);
|
|
986
|
+
return {
|
|
987
|
+
current: {
|
|
988
|
+
flash: {
|
|
989
|
+
cacheReadPerMillion: n(1),
|
|
990
|
+
inputPerMillion: n(5),
|
|
991
|
+
outputPerMillion: n(9)
|
|
992
|
+
},
|
|
993
|
+
pro: {
|
|
994
|
+
cacheReadPerMillion: n(2),
|
|
995
|
+
inputPerMillion: n(6),
|
|
996
|
+
outputPerMillion: n(10)
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
peak: {
|
|
1000
|
+
flash: {
|
|
1001
|
+
offPeak: {
|
|
1002
|
+
cacheReadPerMillion: n(1),
|
|
1003
|
+
inputPerMillion: n(5),
|
|
1004
|
+
outputPerMillion: n(9)
|
|
1005
|
+
},
|
|
1006
|
+
peak: {
|
|
1007
|
+
cacheReadPerMillion: n(3),
|
|
1008
|
+
inputPerMillion: n(7),
|
|
1009
|
+
outputPerMillion: n(11)
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
pro: {
|
|
1013
|
+
offPeak: {
|
|
1014
|
+
cacheReadPerMillion: n(2),
|
|
1015
|
+
inputPerMillion: n(6),
|
|
1016
|
+
outputPerMillion: n(10)
|
|
1017
|
+
},
|
|
1018
|
+
peak: {
|
|
1019
|
+
cacheReadPerMillion: n(4),
|
|
1020
|
+
inputPerMillion: n(8),
|
|
1021
|
+
outputPerMillion: n(12)
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Parse the English current single-price table.
|
|
1029
|
+
* @param html - the raw English pricing page.
|
|
1030
|
+
* @returns the parsed table, or undefined when the labels are absent.
|
|
1031
|
+
*/
|
|
1032
|
+
function parseCurrentTableEn(html) {
|
|
1033
|
+
return parseEnglishPricing(html)?.current;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Parse the English upcoming peak-pricing table.
|
|
1037
|
+
* @param html - the raw English pricing page.
|
|
1038
|
+
* @returns the parsed table, or undefined when either model row is absent.
|
|
1039
|
+
*/
|
|
1040
|
+
function parsePeakTableEn(html) {
|
|
1041
|
+
return parseEnglishPricing(html)?.peak;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Fetch and parse the official pricing page.
|
|
1045
|
+
* @param fetchImpl - fetch-compatible function (injected for testability).
|
|
1046
|
+
* @param timeoutMs - abort timeout.
|
|
1047
|
+
* @returns the parsed snapshot; `error` is set when fetch/parse failed.
|
|
1048
|
+
*/
|
|
1049
|
+
async function fetchPricing(fetchImpl = globalThis.fetch, timeoutMs = 15e3, locale = "zh") {
|
|
1050
|
+
const fetchedAt = Date.now();
|
|
1051
|
+
const currency = locale === "en" ? "USD" : "CNY";
|
|
1052
|
+
const url = locale === "en" ? PRICING_URL_EN : PRICING_URL;
|
|
1053
|
+
const parseCurrent = locale === "en" ? parseCurrentTableEn : parseCurrentTable;
|
|
1054
|
+
const parsePeak = locale === "en" ? parsePeakTableEn : parsePeakTable;
|
|
1055
|
+
const fallbackCurrent = locale === "en" ? FALLBACK_CURRENT_USD : FALLBACK_CURRENT;
|
|
1056
|
+
try {
|
|
1057
|
+
const controller = new AbortController();
|
|
1058
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1059
|
+
let response;
|
|
1060
|
+
try {
|
|
1061
|
+
response = await fetchImpl(url, { signal: controller.signal });
|
|
1062
|
+
} finally {
|
|
1063
|
+
clearTimeout(timer);
|
|
1064
|
+
}
|
|
1065
|
+
if (!response.ok) return {
|
|
1066
|
+
fetchedAt,
|
|
1067
|
+
currency,
|
|
1068
|
+
current: fallbackCurrent,
|
|
1069
|
+
peakActive: false,
|
|
1070
|
+
effective: fallbackCurrent,
|
|
1071
|
+
error: `pricing page HTTP ${response.status}`
|
|
1072
|
+
};
|
|
1073
|
+
const html = await response.text();
|
|
1074
|
+
const current = parseCurrent(html);
|
|
1075
|
+
if (current === void 0) return {
|
|
1076
|
+
fetchedAt,
|
|
1077
|
+
currency,
|
|
1078
|
+
current: fallbackCurrent,
|
|
1079
|
+
peakActive: false,
|
|
1080
|
+
effective: fallbackCurrent,
|
|
1081
|
+
error: "pricing table not found"
|
|
1082
|
+
};
|
|
1083
|
+
const peak = parsePeak(html);
|
|
1084
|
+
const now = Date.now();
|
|
1085
|
+
const peakActive = now >= PEAK_PRICING_START_MS;
|
|
1086
|
+
return {
|
|
1087
|
+
fetchedAt,
|
|
1088
|
+
currency,
|
|
1089
|
+
current,
|
|
1090
|
+
...peak === void 0 ? {} : { peak },
|
|
1091
|
+
peakActive,
|
|
1092
|
+
effective: effectivePricing(current, peak, peakActive, new Date(now)),
|
|
1093
|
+
...peakActive ? { band: isPeakHour(new Date(now)) ? "peak" : "offPeak" } : {}
|
|
1094
|
+
};
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
return {
|
|
1097
|
+
fetchedAt,
|
|
1098
|
+
currency,
|
|
1099
|
+
current: fallbackCurrent,
|
|
1100
|
+
peakActive: false,
|
|
1101
|
+
effective: fallbackCurrent,
|
|
1102
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Whether the current moment is a peak-pricing hour in Beijing time:
|
|
1108
|
+
* 09:00-12:00 and 14:00-18:00 (peak); everything else is off-peak.
|
|
1109
|
+
* @param now - the instant to classify.
|
|
1110
|
+
* @returns true during peak hours.
|
|
1111
|
+
*/
|
|
1112
|
+
function isPeakHour(now = /* @__PURE__ */ new Date()) {
|
|
1113
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
1114
|
+
timeZone: "Asia/Shanghai",
|
|
1115
|
+
hour: "numeric",
|
|
1116
|
+
hour12: false
|
|
1117
|
+
}).formatToParts(now);
|
|
1118
|
+
const hour = Number(parts.find((part) => part.type === "hour")?.value);
|
|
1119
|
+
if (Number.isNaN(hour)) return false;
|
|
1120
|
+
return hour >= 9 && hour < 12 || hour >= 14 && hour < 18;
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* The price bucket in effect for one model at the given moment: the
|
|
1124
|
+
* peak/off-peak band once the rollout is live (with the parsed table, or the
|
|
1125
|
+
* built-in fallback), otherwise the current official list.
|
|
1126
|
+
* @param current - the current list prices.
|
|
1127
|
+
* @param peak - the upcoming peak table, when parsed.
|
|
1128
|
+
* @param peakActive - whether the rollout has started.
|
|
1129
|
+
* @param now - the instant to price for.
|
|
1130
|
+
* @param model - `flash` or `pro`.
|
|
1131
|
+
* @returns the bucket to apply.
|
|
1132
|
+
*/
|
|
1133
|
+
function effectiveBucket(current, peak, peakActive, now, model) {
|
|
1134
|
+
if (peakActive) {
|
|
1135
|
+
const table = peak?.[model] ?? FALLBACK_PEAK[model];
|
|
1136
|
+
return isPeakHour(now) ? table.peak : table.offPeak;
|
|
1137
|
+
}
|
|
1138
|
+
return current[model];
|
|
1139
|
+
}
|
|
1140
|
+
/** Per-model effective prices at the given moment. */
|
|
1141
|
+
function effectivePricing(current, peak, peakActive, now) {
|
|
1142
|
+
return {
|
|
1143
|
+
flash: effectiveBucket(current, peak, peakActive, now, "flash"),
|
|
1144
|
+
pro: effectiveBucket(current, peak, peakActive, now, "pro")
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/pricebook.ts
|
|
1149
|
+
/** Default manual rate used when auto-fetch fails and nothing was ever fetched. */
|
|
1150
|
+
const DEFAULT_FX_RATE = 7.2;
|
|
1151
|
+
/** Default OpenRouter cache-read fraction of the uncached input price. */
|
|
1152
|
+
const DEFAULT_CACHE_READ_DISCOUNT = .25;
|
|
1153
|
+
/** Default OpenRouter model-id → canonical key aliases (editable). */
|
|
1154
|
+
const DEFAULT_ALIASES = {
|
|
1155
|
+
"deepseek/deepseek-chat": "deepseek-v4-flash",
|
|
1156
|
+
"deepseek/deepseek-reasoner": "deepseek-v4-pro"
|
|
1157
|
+
};
|
|
1158
|
+
/** Free exchange-rate endpoint (no key); returns `rates.CNY` for USD base. */
|
|
1159
|
+
const FX_API_URL = "https://open.er-api.com/v6/latest/USD";
|
|
1160
|
+
/** OpenRouter models directory (prices in USD per token). */
|
|
1161
|
+
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
1162
|
+
/** Default cap on the persisted snapshot history. */
|
|
1163
|
+
const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
|
|
1164
|
+
const bucketSchema = z.object({
|
|
1165
|
+
cacheReadPerMillion: z.number().nonnegative(),
|
|
1166
|
+
inputPerMillion: z.number().nonnegative(),
|
|
1167
|
+
outputPerMillion: z.number().nonnegative()
|
|
1168
|
+
}).strict();
|
|
1169
|
+
const modelPriceSchema$1 = z.object({
|
|
1170
|
+
source: z.enum([
|
|
1171
|
+
"manual",
|
|
1172
|
+
"official",
|
|
1173
|
+
"fallback",
|
|
1174
|
+
"openrouter",
|
|
1175
|
+
"none"
|
|
1176
|
+
]),
|
|
1177
|
+
single: bucketSchema.optional(),
|
|
1178
|
+
offPeak: bucketSchema.optional(),
|
|
1179
|
+
peak: bucketSchema.optional()
|
|
1180
|
+
}).strict();
|
|
1181
|
+
const snapshotSchema = z.object({
|
|
1182
|
+
version: z.number().int().positive(),
|
|
1183
|
+
currency: z.enum(["CNY", "USD"]).default("CNY"),
|
|
1184
|
+
effectiveAt: z.number().int().nonnegative(),
|
|
1185
|
+
source: z.enum([
|
|
1186
|
+
"manual",
|
|
1187
|
+
"official",
|
|
1188
|
+
"fallback",
|
|
1189
|
+
"openrouter",
|
|
1190
|
+
"none"
|
|
1191
|
+
]),
|
|
1192
|
+
usdCnyRate: z.number().nullable(),
|
|
1193
|
+
prices: z.record(z.string(), modelPriceSchema$1)
|
|
1194
|
+
}).strict();
|
|
1195
|
+
const fxSchema = z.object({
|
|
1196
|
+
mode: z.enum(["auto", "manual"]),
|
|
1197
|
+
manualRate: z.number().positive(),
|
|
1198
|
+
lastAutoRate: z.number().positive().optional(),
|
|
1199
|
+
rateFetchedAt: z.number().int().nonnegative().optional()
|
|
1200
|
+
}).strict();
|
|
1201
|
+
const pricebookStateSchema = z.object({
|
|
1202
|
+
snapshots: z.array(snapshotSchema),
|
|
1203
|
+
overrides: z.record(z.string(), modelPriceSchema$1),
|
|
1204
|
+
aliases: z.record(z.string(), z.string()),
|
|
1205
|
+
cacheReadDiscount: z.number().min(0).max(1),
|
|
1206
|
+
fx: fxSchema,
|
|
1207
|
+
balanceEnabled: z.boolean(),
|
|
1208
|
+
openRouterEnabled: z.boolean(),
|
|
1209
|
+
updatedAt: z.number().int().nonnegative()
|
|
1210
|
+
}).strict();
|
|
1211
|
+
/** Fresh pricebook state before any user configuration exists. */
|
|
1212
|
+
function initialPricebookState() {
|
|
1213
|
+
return {
|
|
1214
|
+
snapshots: [],
|
|
1215
|
+
overrides: {},
|
|
1216
|
+
aliases: { ...DEFAULT_ALIASES },
|
|
1217
|
+
cacheReadDiscount: DEFAULT_CACHE_READ_DISCOUNT,
|
|
1218
|
+
fx: {
|
|
1219
|
+
mode: "auto",
|
|
1220
|
+
manualRate: DEFAULT_FX_RATE
|
|
1221
|
+
},
|
|
1222
|
+
balanceEnabled: true,
|
|
1223
|
+
openRouterEnabled: true,
|
|
1224
|
+
updatedAt: 0
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* The pricebook storage domain. One global slot holds the whole state; the
|
|
1229
|
+
* domain name (`pricebook`) is unused by any shipped row, so the open never
|
|
1230
|
+
* collides with `already-open`.
|
|
1231
|
+
*/
|
|
1232
|
+
const PRICEBOOK_DOMAIN = defineDomain({
|
|
1233
|
+
name: "pricebook",
|
|
1234
|
+
version: 1,
|
|
1235
|
+
global: {
|
|
1236
|
+
schema: pricebookStateSchema,
|
|
1237
|
+
initial: initialPricebookState()
|
|
1238
|
+
},
|
|
1239
|
+
tables: {}
|
|
1240
|
+
});
|
|
1241
|
+
/** USD pricebook storage domain, kept separate from the CNY domain. */
|
|
1242
|
+
const PRICEBOOK_DOMAIN_USD = defineDomain({
|
|
1243
|
+
name: "pricebook_usd",
|
|
1244
|
+
version: 1,
|
|
1245
|
+
global: {
|
|
1246
|
+
schema: pricebookStateSchema,
|
|
1247
|
+
initial: initialPricebookState()
|
|
1248
|
+
},
|
|
1249
|
+
tables: {}
|
|
1250
|
+
});
|
|
1251
|
+
/**
|
|
1252
|
+
* Map a model id to its official pricing key: anything containing `pro`
|
|
1253
|
+
* prices as pro, `flash` as flash. Mirror of the client-side helper.
|
|
1254
|
+
* @param model - the model id.
|
|
1255
|
+
* @returns the pricing key.
|
|
1256
|
+
*/
|
|
1257
|
+
function pricingKeyOfModel(model) {
|
|
1258
|
+
return model.toLowerCase().includes("pro") ? "pro" : "flash";
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Candidate canonical price keys for one model, most specific first. The
|
|
1262
|
+
* pricing-key fallbacks (`pro`/`flash`) only apply to DeepSeek-family models
|
|
1263
|
+
* (id or provider names deepseek): an unrelated model must NOT silently pick
|
|
1264
|
+
* up the flash list price — it either resolves through an override, an
|
|
1265
|
+
* OpenRouter alias, or stays unpriced.
|
|
1266
|
+
* @param provider - the provider route, when known.
|
|
1267
|
+
* @param model - the model id.
|
|
1268
|
+
* @returns candidate keys in lookup order.
|
|
1269
|
+
*/
|
|
1270
|
+
function modelKeys(provider, model) {
|
|
1271
|
+
const keys = [];
|
|
1272
|
+
if (provider !== void 0 && provider.length > 0) keys.push(`${provider}/${model}`);
|
|
1273
|
+
keys.push(model);
|
|
1274
|
+
if (model.toLowerCase().includes("deepseek") || provider !== void 0 && provider.toLowerCase().includes("deepseek")) keys.push(pricingKeyOfModel(model));
|
|
1275
|
+
return keys;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* The price band in effect at one instant: pre-rollout single price, or the
|
|
1279
|
+
* peak/off-peak band once the rollout is live.
|
|
1280
|
+
* @param time - epoch millis.
|
|
1281
|
+
* @returns the band.
|
|
1282
|
+
*/
|
|
1283
|
+
function bandForTime(time) {
|
|
1284
|
+
if (time < PEAK_PRICING_START_MS) return "single";
|
|
1285
|
+
return isPeakHour(new Date(time)) ? "peak" : "offPeak";
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* The bucket of one model's price entry for one band, with graceful
|
|
1289
|
+
* degradation to whatever tier exists.
|
|
1290
|
+
* @param modelPrice - the model's price entry.
|
|
1291
|
+
* @param band - the band to price for.
|
|
1292
|
+
* @returns the bucket, or undefined when the entry carries no tier at all.
|
|
1293
|
+
*/
|
|
1294
|
+
function bucketOf(modelPrice, band) {
|
|
1295
|
+
if (band === "single") return modelPrice.single ?? modelPrice.offPeak ?? modelPrice.peak;
|
|
1296
|
+
if (band === "peak") return modelPrice.peak ?? modelPrice.offPeak ?? modelPrice.single;
|
|
1297
|
+
return modelPrice.offPeak ?? modelPrice.peak ?? modelPrice.single;
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Resolve one model's bucket at one instant from its price entry.
|
|
1301
|
+
* @param modelPrice - the model's price entry.
|
|
1302
|
+
* @param time - the instant to price for.
|
|
1303
|
+
* @returns the bucket and band, or null when the entry has no tier.
|
|
1304
|
+
*/
|
|
1305
|
+
function priceAt(modelPrice, time) {
|
|
1306
|
+
const band = bandForTime(time);
|
|
1307
|
+
const bucket = bucketOf(modelPrice, band);
|
|
1308
|
+
return bucket === void 0 ? null : {
|
|
1309
|
+
bucket,
|
|
1310
|
+
band
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Pick the snapshot effective at `time`: the newest with `effectiveAt <=
|
|
1315
|
+
* time`, or the earliest available when no snapshot predates it (pre-install
|
|
1316
|
+
* sessions anchor to the first snapshot once, as required).
|
|
1317
|
+
* @param snapshots - the snapshot history, any order.
|
|
1318
|
+
* @param time - the instant to anchor.
|
|
1319
|
+
* @returns the snapshot, or undefined when the history is empty.
|
|
1320
|
+
*/
|
|
1321
|
+
function snapshotForTime(snapshots, time) {
|
|
1322
|
+
let best;
|
|
1323
|
+
for (const snapshot of snapshots) if (snapshot.effectiveAt <= time && (best === void 0 || snapshot.effectiveAt >= best.effectiveAt)) best = snapshot;
|
|
1324
|
+
return best ?? (snapshots.length > 0 ? snapshots[0] : void 0);
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* The official cost formula, per million tokens: uncached input + cache
|
|
1328
|
+
* reads at the hit rate + cache writes at the uncached input rate (DeepSeek
|
|
1329
|
+
* reports only hit/miss buckets and bills writes as misses) + output.
|
|
1330
|
+
* @param usage - the step's token buckets.
|
|
1331
|
+
* @param bucket - the effective price bucket.
|
|
1332
|
+
* @returns the cost in the bucket's currency (CNY).
|
|
1333
|
+
*/
|
|
1334
|
+
function stepCost(usage, bucket) {
|
|
1335
|
+
return (usage.uncached * bucket.inputPerMillion + usage.cacheRead * bucket.cacheReadPerMillion + usage.cacheWrite * bucket.inputPerMillion + usage.output * bucket.outputPerMillion) / 1e6;
|
|
1336
|
+
}
|
|
1337
|
+
function bucketEqual(left, right) {
|
|
1338
|
+
if (left === void 0 || right === void 0) return left === right;
|
|
1339
|
+
return Math.abs(left.cacheReadPerMillion - right.cacheReadPerMillion) < 1e-9 && Math.abs(left.inputPerMillion - right.inputPerMillion) < 1e-9 && Math.abs(left.outputPerMillion - right.outputPerMillion) < 1e-9;
|
|
1340
|
+
}
|
|
1341
|
+
function modelPriceEqual(left, right) {
|
|
1342
|
+
if (left.source !== right.source) return false;
|
|
1343
|
+
return bucketEqual(left.single, right.single) && bucketEqual(left.offPeak, right.offPeak) && bucketEqual(left.peak, right.peak);
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Whether two per-model price tables are equivalent (epsilon-compared).
|
|
1347
|
+
* Used to skip a new snapshot when a refresh changed nothing user-visible.
|
|
1348
|
+
*/
|
|
1349
|
+
function pricesEqual(left, right) {
|
|
1350
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
1351
|
+
for (const key of keys) {
|
|
1352
|
+
const a = left[key];
|
|
1353
|
+
const b = right[key];
|
|
1354
|
+
if (a === void 0 || b === void 0) return false;
|
|
1355
|
+
if (!modelPriceEqual(a, b)) return false;
|
|
1356
|
+
}
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
/** Effective USD→CNY rate from the configured mode plus fallbacks. */
|
|
1360
|
+
function effectiveRate(fx, defaultRate) {
|
|
1361
|
+
if (fx.mode === "manual") return fx.manualRate;
|
|
1362
|
+
return fx.lastAutoRate ?? fx.manualRate ?? defaultRate;
|
|
1363
|
+
}
|
|
1364
|
+
/** Whether one canonical key already resolves to a non-OpenRouter (domestic) price. */
|
|
1365
|
+
function hasDomesticPrice(prices, key) {
|
|
1366
|
+
return (key.includes("/") ? [key] : [key, pricingKeyOfModel(key)]).some((candidate) => {
|
|
1367
|
+
const entry = prices[candidate];
|
|
1368
|
+
return entry !== void 0 && entry.source !== "openrouter";
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Merge every source into one per-model price table through the priority
|
|
1373
|
+
* chain: manual overrides win; official (or the built-in fallback when the
|
|
1374
|
+
* page failed) covers the flash/pro pricing keys; OpenRouter covers only
|
|
1375
|
+
* models with no domestic price (fallback-only, USD→CNY, cache reads at the
|
|
1376
|
+
* configured discount). OpenRouter prices land under the aliased canonical
|
|
1377
|
+
* key so unaliased model ids still resolve through the key chain.
|
|
1378
|
+
* @param state - the pricebook state (overrides, aliases, discount, fx).
|
|
1379
|
+
* @param official - the official-page input (fallback table on failure).
|
|
1380
|
+
* @param openrouter - the OpenRouter input, or null when disabled/failed.
|
|
1381
|
+
* @param usdCnyRate - the effective exchange rate.
|
|
1382
|
+
* @param defaultRate - the configuration default rate (fallback chain end).
|
|
1383
|
+
* @returns the merged table and the highest-priority contributing source.
|
|
1384
|
+
*/
|
|
1385
|
+
function computePricebook(state, official, openrouter, usdCnyRate, defaultRate, fallbackPeak = FALLBACK_PEAK, currency = "CNY") {
|
|
1386
|
+
const prices = {};
|
|
1387
|
+
for (const [key, override] of Object.entries(state.overrides)) prices[key] = {
|
|
1388
|
+
...override,
|
|
1389
|
+
source: "manual"
|
|
1390
|
+
};
|
|
1391
|
+
const officialSource = official.error === void 0 ? "official" : "fallback";
|
|
1392
|
+
for (const key of ["flash", "pro"]) {
|
|
1393
|
+
if (prices[key] !== void 0) continue;
|
|
1394
|
+
const peakTable = official.peak ?? fallbackPeak;
|
|
1395
|
+
prices[key] = {
|
|
1396
|
+
source: officialSource,
|
|
1397
|
+
single: official.current[key],
|
|
1398
|
+
offPeak: peakTable[key].offPeak,
|
|
1399
|
+
peak: peakTable[key].peak
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
const openRouterFx = currency === "USD" ? 1 : usdCnyRate;
|
|
1403
|
+
if (openrouter !== null && openrouter.error === void 0 && (currency === "USD" || usdCnyRate > 0)) {
|
|
1404
|
+
const discount = state.cacheReadDiscount;
|
|
1405
|
+
for (const [orId, usd] of Object.entries(openrouter.usdPrices)) {
|
|
1406
|
+
const alias = state.aliases[orId] ?? orId;
|
|
1407
|
+
if (hasDomesticPrice(prices, alias)) continue;
|
|
1408
|
+
if (hasDomesticPrice(prices, orId)) continue;
|
|
1409
|
+
const entry = {
|
|
1410
|
+
source: "openrouter",
|
|
1411
|
+
single: {
|
|
1412
|
+
cacheReadPerMillion: usd.inputPerMillionUsd * discount * openRouterFx,
|
|
1413
|
+
inputPerMillion: usd.inputPerMillionUsd * openRouterFx,
|
|
1414
|
+
outputPerMillion: usd.outputPerMillionUsd * openRouterFx
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
prices[alias] = entry;
|
|
1418
|
+
if (alias !== orId) prices[orId] = entry;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
let primary = "none";
|
|
1422
|
+
for (const candidate of [
|
|
1423
|
+
"manual",
|
|
1424
|
+
"official",
|
|
1425
|
+
"fallback",
|
|
1426
|
+
"openrouter"
|
|
1427
|
+
]) if (Object.values(prices).some((entry) => entry.source === candidate)) {
|
|
1428
|
+
primary = candidate;
|
|
1429
|
+
break;
|
|
1430
|
+
}
|
|
1431
|
+
return {
|
|
1432
|
+
prices,
|
|
1433
|
+
primarySource: primary
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Fetch the USD→CNY rate from the free endpoint. Never throws; the caller
|
|
1438
|
+
* applies the fallback chain (last auto rate → manual/default rate).
|
|
1439
|
+
* @param fetchImpl - fetch-compatible function (injected for testability).
|
|
1440
|
+
* @param url - the endpoint.
|
|
1441
|
+
* @param timeoutMs - abort timeout.
|
|
1442
|
+
* @returns the fresh rate, or an error result.
|
|
1443
|
+
*/
|
|
1444
|
+
async function fetchFxRate(fetchImpl = globalThis.fetch, url = FX_API_URL, timeoutMs = 1e4) {
|
|
1445
|
+
try {
|
|
1446
|
+
const controller = new AbortController();
|
|
1447
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1448
|
+
let response;
|
|
1449
|
+
try {
|
|
1450
|
+
response = await fetchImpl(url, { signal: controller.signal });
|
|
1451
|
+
} finally {
|
|
1452
|
+
clearTimeout(timer);
|
|
1453
|
+
}
|
|
1454
|
+
if (!response.ok) return {
|
|
1455
|
+
rate: null,
|
|
1456
|
+
error: `fx endpoint HTTP ${response.status}`
|
|
1457
|
+
};
|
|
1458
|
+
const wire = await response.json();
|
|
1459
|
+
const cny = Number(wire.rates?.CNY);
|
|
1460
|
+
if (!Number.isFinite(cny) || cny <= 0) return {
|
|
1461
|
+
rate: null,
|
|
1462
|
+
error: "fx response carries no rates.CNY"
|
|
1463
|
+
};
|
|
1464
|
+
return { rate: cny };
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
return {
|
|
1467
|
+
rate: null,
|
|
1468
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Fetch the OpenRouter models directory and fold it into USD-per-1M-token
|
|
1474
|
+
* buckets. Every entry with a valid prompt/completion price is kept: the
|
|
1475
|
+
* directory is small (a few hundred rows) and the alias map is editable, so
|
|
1476
|
+
* a newly aliased model must resolve without waiting for the next refresh.
|
|
1477
|
+
* Never throws.
|
|
1478
|
+
* @param fetchImpl - fetch-compatible function (injected for testability).
|
|
1479
|
+
* @param url - the directory endpoint.
|
|
1480
|
+
* @param timeoutMs - abort timeout.
|
|
1481
|
+
* @param aliases - the current alias map (kept for parity, not used as a filter).
|
|
1482
|
+
* @returns the model price table, or an error result.
|
|
1483
|
+
*/
|
|
1484
|
+
async function fetchOpenRouter(fetchImpl = globalThis.fetch, url = OPENROUTER_MODELS_URL, timeoutMs = 15e3, aliases = DEFAULT_ALIASES) {
|
|
1485
|
+
try {
|
|
1486
|
+
const controller = new AbortController();
|
|
1487
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1488
|
+
let response;
|
|
1489
|
+
try {
|
|
1490
|
+
response = await fetchImpl(url, { signal: controller.signal });
|
|
1491
|
+
} finally {
|
|
1492
|
+
clearTimeout(timer);
|
|
1493
|
+
}
|
|
1494
|
+
if (!response.ok) return {
|
|
1495
|
+
usdPrices: {},
|
|
1496
|
+
error: `openrouter models HTTP ${response.status}`
|
|
1497
|
+
};
|
|
1498
|
+
const wire = await response.json();
|
|
1499
|
+
const usdPrices = {};
|
|
1500
|
+
for (const entry of wire.data ?? []) {
|
|
1501
|
+
if (typeof entry.id !== "string" || entry.pricing === void 0) continue;
|
|
1502
|
+
const prompt = Number(entry.pricing.prompt);
|
|
1503
|
+
const completion = Number(entry.pricing.completion);
|
|
1504
|
+
if (!Number.isFinite(prompt) || !Number.isFinite(completion) || prompt <= 0 || completion <= 0) continue;
|
|
1505
|
+
usdPrices[entry.id] = {
|
|
1506
|
+
inputPerMillionUsd: prompt * 1e6,
|
|
1507
|
+
outputPerMillionUsd: completion * 1e6
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
return { usdPrices };
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
return {
|
|
1513
|
+
usdPrices: {},
|
|
1514
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/** Fold a raw {@link PricingSnapshot} into the pricebook's official input. */
|
|
1519
|
+
function officialInputOf(snapshot) {
|
|
1520
|
+
return {
|
|
1521
|
+
current: snapshot.current,
|
|
1522
|
+
...snapshot.peak === void 0 ? {} : { peak: snapshot.peak },
|
|
1523
|
+
...snapshot.error === void 0 ? {} : { error: snapshot.error }
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The synchronous read face the sessionCost projection folds against, plus
|
|
1528
|
+
* the mutation/refresh/persist surface the plugin's apply and route use.
|
|
1529
|
+
* Reads never touch the domain (mirror only); writes update the mirror first,
|
|
1530
|
+
* then queue a fail-soft durable write.
|
|
1531
|
+
*/
|
|
1532
|
+
var PricebookHandle = class {
|
|
1533
|
+
ctx;
|
|
1534
|
+
config;
|
|
1535
|
+
currency;
|
|
1536
|
+
/** The authoritative in-memory state (mirror of the domain global). */
|
|
1537
|
+
state;
|
|
1538
|
+
domain;
|
|
1539
|
+
persistTail = Promise.resolve();
|
|
1540
|
+
lastOfficial;
|
|
1541
|
+
lastOpenRouter = null;
|
|
1542
|
+
lastFxError;
|
|
1543
|
+
fetchedAt = 0;
|
|
1544
|
+
constructor(ctx, config, persisted, currency = "CNY") {
|
|
1545
|
+
this.ctx = ctx;
|
|
1546
|
+
this.config = config;
|
|
1547
|
+
this.currency = currency;
|
|
1548
|
+
this.state = persisted ?? {
|
|
1549
|
+
...initialPricebookState(),
|
|
1550
|
+
openRouterEnabled: config.openRouterEnabled
|
|
1551
|
+
};
|
|
1552
|
+
this.lastOfficial = { current: currency === "USD" ? FALLBACK_CURRENT_USD : FALLBACK_CURRENT };
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Open the storage domain and adopt the persisted state. Never throws:
|
|
1556
|
+
* a missing storageDomain or a failed open degrades to in-memory only.
|
|
1557
|
+
*/
|
|
1558
|
+
async init() {
|
|
1559
|
+
const storageDomain = this.ctx.get("storageDomain");
|
|
1560
|
+
if (storageDomain === void 0) return;
|
|
1561
|
+
try {
|
|
1562
|
+
const domain = this.currency === "USD" ? PRICEBOOK_DOMAIN_USD : PRICEBOOK_DOMAIN;
|
|
1563
|
+
this.domain = await storageDomain.open(domain);
|
|
1564
|
+
this.state = this.domain.global.get();
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
console.warn(`cost-meter: pricebook domain unavailable, keeping in-memory state: ${error instanceof Error ? error.message : String(error)}`);
|
|
1567
|
+
this.domain = void 0;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
/** Queue one durable write of the whole state (fail-soft, serialized). */
|
|
1571
|
+
persist() {
|
|
1572
|
+
if (this.domain === void 0) return;
|
|
1573
|
+
const state = this.state;
|
|
1574
|
+
this.persistTail = this.persistTail.then(() => this.domain.global.set(state)).catch((error) => {
|
|
1575
|
+
console.warn(`cost-meter: pricebook persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
/** Resolve once every queued durable write has settled. */
|
|
1579
|
+
async whenIdle() {
|
|
1580
|
+
await this.persistTail;
|
|
1581
|
+
}
|
|
1582
|
+
/** Release the domain (called from the plugin's disposer). */
|
|
1583
|
+
async close() {
|
|
1584
|
+
await this.whenIdle();
|
|
1585
|
+
if (this.domain !== void 0) {
|
|
1586
|
+
const domain = this.domain;
|
|
1587
|
+
this.domain = void 0;
|
|
1588
|
+
await domain.close();
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
/** The newest snapshot, or undefined before the first refresh. */
|
|
1592
|
+
currentSnapshot() {
|
|
1593
|
+
const snapshots = this.state.snapshots;
|
|
1594
|
+
return snapshots.length > 0 ? snapshots[snapshots.length - 1] : void 0;
|
|
1595
|
+
}
|
|
1596
|
+
/** Pick the snapshot effective at `time`. */
|
|
1597
|
+
snapshotFor(time) {
|
|
1598
|
+
return snapshotForTime(this.state.snapshots, time);
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Resolve the price of one model at one instant through the key chain and
|
|
1602
|
+
* the anchored snapshot. Synchronous: the fold calls this per usage event.
|
|
1603
|
+
* @param provider - the provider route, when known.
|
|
1604
|
+
* @param model - the model id.
|
|
1605
|
+
* @param time - the instant to price for.
|
|
1606
|
+
* @returns the hit, or null when no source carries a price.
|
|
1607
|
+
*/
|
|
1608
|
+
priceFor(provider, model, time) {
|
|
1609
|
+
const snapshot = this.snapshotFor(time);
|
|
1610
|
+
if (snapshot === void 0) return null;
|
|
1611
|
+
for (const key of modelKeys(provider, model)) {
|
|
1612
|
+
const entry = snapshot.prices[key];
|
|
1613
|
+
if (entry === void 0) continue;
|
|
1614
|
+
const hit = priceAt(entry, time);
|
|
1615
|
+
if (hit === null) continue;
|
|
1616
|
+
return {
|
|
1617
|
+
bucket: hit.bucket,
|
|
1618
|
+
band: hit.band,
|
|
1619
|
+
source: entry.source,
|
|
1620
|
+
snapshotVersion: snapshot.version,
|
|
1621
|
+
snapshotEffectiveAt: snapshot.effectiveAt
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
return null;
|
|
1625
|
+
}
|
|
1626
|
+
/** The effective USD→CNY rate under the current configuration. */
|
|
1627
|
+
rate() {
|
|
1628
|
+
return effectiveRate(this.state.fx, this.config.defaultFxRate);
|
|
1629
|
+
}
|
|
1630
|
+
/** Cap the snapshot history to the configured limit (newest kept). */
|
|
1631
|
+
capSnapshots() {
|
|
1632
|
+
const limit = this.config.snapshotHistoryLimit;
|
|
1633
|
+
if (this.state.snapshots.length > limit) this.state = {
|
|
1634
|
+
...this.state,
|
|
1635
|
+
snapshots: this.state.snapshots.slice(-limit)
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Recompute the resolved table from the cached inputs and start a new
|
|
1640
|
+
* snapshot when anything user-visible changed. Shared by the startup
|
|
1641
|
+
* refresh, the periodic refresh, the manual refresh, and every settings
|
|
1642
|
+
* mutation.
|
|
1643
|
+
*/
|
|
1644
|
+
recompute(now) {
|
|
1645
|
+
const openrouter = this.config.openRouterEnabled ? this.lastOpenRouter : null;
|
|
1646
|
+
const { prices, primarySource } = computePricebook(this.state, this.lastOfficial, openrouter, this.rate(), this.config.defaultFxRate, this.currency === "USD" ? FALLBACK_PEAK_USD : FALLBACK_PEAK, this.currency);
|
|
1647
|
+
const last = this.currentSnapshot();
|
|
1648
|
+
if (last !== void 0 && pricesEqual(last.prices, prices)) return;
|
|
1649
|
+
const snapshot = {
|
|
1650
|
+
version: (last?.version ?? 0) + 1,
|
|
1651
|
+
currency: this.currency,
|
|
1652
|
+
effectiveAt: now,
|
|
1653
|
+
source: primarySource,
|
|
1654
|
+
usdCnyRate: this.rate(),
|
|
1655
|
+
prices
|
|
1656
|
+
};
|
|
1657
|
+
this.state = {
|
|
1658
|
+
...this.state,
|
|
1659
|
+
snapshots: [...this.state.snapshots, snapshot],
|
|
1660
|
+
updatedAt: now
|
|
1661
|
+
};
|
|
1662
|
+
this.capSnapshots();
|
|
1663
|
+
this.persist();
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Refresh every remote source (official page, exchange rate, OpenRouter
|
|
1667
|
+
* when enabled) and re-anchor a snapshot on change. Never throws.
|
|
1668
|
+
* @param options - injected results (testability); omitted values are fetched.
|
|
1669
|
+
*/
|
|
1670
|
+
async refresh(options) {
|
|
1671
|
+
const official = options?.official ?? await fetchPricing(globalThis.fetch, 15e3, this.currency === "USD" ? "en" : "zh");
|
|
1672
|
+
this.lastOfficial = officialInputOf(official);
|
|
1673
|
+
this.lastFxError = void 0;
|
|
1674
|
+
if (options?.fx !== void 0) this.applyFxResult(options.fx.rate, options.fx.error);
|
|
1675
|
+
else if (this.state.fx.mode === "auto") {
|
|
1676
|
+
const fx = await fetchFxRate(globalThis.fetch, this.config.fxApiUrl);
|
|
1677
|
+
this.applyFxResult(fx.rate, fx.error);
|
|
1678
|
+
}
|
|
1679
|
+
if (this.state.openRouterEnabled) this.lastOpenRouter = options?.openrouter ?? await fetchOpenRouter(globalThis.fetch, this.config.openRouterModelsUrl, 15e3, this.state.aliases);
|
|
1680
|
+
else this.lastOpenRouter = null;
|
|
1681
|
+
this.fetchedAt = Date.now();
|
|
1682
|
+
this.recompute(this.fetchedAt);
|
|
1683
|
+
}
|
|
1684
|
+
applyFxResult(rate, error) {
|
|
1685
|
+
this.lastFxError = error;
|
|
1686
|
+
if (rate !== null) this.state = {
|
|
1687
|
+
...this.state,
|
|
1688
|
+
fx: {
|
|
1689
|
+
...this.state.fx,
|
|
1690
|
+
lastAutoRate: rate,
|
|
1691
|
+
rateFetchedAt: Date.now()
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
/** Apply a settings mutation and re-anchor (snapshot on change). */
|
|
1696
|
+
mutate(mutator) {
|
|
1697
|
+
this.state = mutator(this.state);
|
|
1698
|
+
this.recompute(Date.now());
|
|
1699
|
+
this.persist();
|
|
1700
|
+
}
|
|
1701
|
+
async setOverrides(overrides) {
|
|
1702
|
+
this.mutate((state) => ({
|
|
1703
|
+
...state,
|
|
1704
|
+
overrides
|
|
1705
|
+
}));
|
|
1706
|
+
await this.whenIdle();
|
|
1707
|
+
}
|
|
1708
|
+
async setAliases(aliases) {
|
|
1709
|
+
this.state = {
|
|
1710
|
+
...this.state,
|
|
1711
|
+
aliases
|
|
1712
|
+
};
|
|
1713
|
+
if (this.state.openRouterEnabled) this.lastOpenRouter = await fetchOpenRouter(globalThis.fetch, this.config.openRouterModelsUrl, 15e3, aliases);
|
|
1714
|
+
this.recompute(Date.now());
|
|
1715
|
+
await this.whenIdle();
|
|
1716
|
+
}
|
|
1717
|
+
async setCacheReadDiscount(discount) {
|
|
1718
|
+
this.mutate((state) => ({
|
|
1719
|
+
...state,
|
|
1720
|
+
cacheReadDiscount: discount
|
|
1721
|
+
}));
|
|
1722
|
+
await this.whenIdle();
|
|
1723
|
+
}
|
|
1724
|
+
async setFx(mode, manualRate) {
|
|
1725
|
+
this.mutate((state) => ({
|
|
1726
|
+
...state,
|
|
1727
|
+
fx: {
|
|
1728
|
+
...state.fx,
|
|
1729
|
+
mode,
|
|
1730
|
+
manualRate
|
|
1731
|
+
}
|
|
1732
|
+
}));
|
|
1733
|
+
await this.whenIdle();
|
|
1734
|
+
}
|
|
1735
|
+
async setBalanceEnabled(enabled) {
|
|
1736
|
+
this.mutate((state) => ({
|
|
1737
|
+
...state,
|
|
1738
|
+
balanceEnabled: enabled
|
|
1739
|
+
}));
|
|
1740
|
+
await this.whenIdle();
|
|
1741
|
+
}
|
|
1742
|
+
async setOpenRouterEnabled(enabled) {
|
|
1743
|
+
this.mutate((state) => ({
|
|
1744
|
+
...state,
|
|
1745
|
+
openRouterEnabled: enabled
|
|
1746
|
+
}));
|
|
1747
|
+
await this.whenIdle();
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Apply a resolved plugin-settings snapshot (the `cost-meter`
|
|
1751
|
+
* settings namespace) to the pricebook: the editable configuration fields
|
|
1752
|
+
* overwrite the persisted state and the resolved table re-anchors (a new
|
|
1753
|
+
* snapshot starts when anything user-visible changed). Never throws.
|
|
1754
|
+
* @param settings - the settings-section value (may carry only a subset).
|
|
1755
|
+
*/
|
|
1756
|
+
applySettings(settings) {
|
|
1757
|
+
try {
|
|
1758
|
+
let next = this.state;
|
|
1759
|
+
if (settings.overrides !== void 0) next = {
|
|
1760
|
+
...next,
|
|
1761
|
+
overrides: settings.overrides
|
|
1762
|
+
};
|
|
1763
|
+
if (settings.aliases !== void 0) next = {
|
|
1764
|
+
...next,
|
|
1765
|
+
aliases: settings.aliases
|
|
1766
|
+
};
|
|
1767
|
+
if (settings.cacheReadDiscount !== void 0) next = {
|
|
1768
|
+
...next,
|
|
1769
|
+
cacheReadDiscount: settings.cacheReadDiscount
|
|
1770
|
+
};
|
|
1771
|
+
if (settings.fxMode !== void 0 || settings.manualRate !== void 0) next = {
|
|
1772
|
+
...next,
|
|
1773
|
+
fx: {
|
|
1774
|
+
...next.fx,
|
|
1775
|
+
...settings.fxMode === void 0 ? {} : { mode: settings.fxMode },
|
|
1776
|
+
...settings.manualRate === void 0 ? {} : { manualRate: settings.manualRate }
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
if (settings.balanceEnabled !== void 0) next = {
|
|
1780
|
+
...next,
|
|
1781
|
+
balanceEnabled: settings.balanceEnabled
|
|
1782
|
+
};
|
|
1783
|
+
if (settings.openRouterEnabled !== void 0) next = {
|
|
1784
|
+
...next,
|
|
1785
|
+
openRouterEnabled: settings.openRouterEnabled
|
|
1786
|
+
};
|
|
1787
|
+
if (next === this.state) return;
|
|
1788
|
+
this.state = next;
|
|
1789
|
+
if (settings.aliases !== void 0 && next.openRouterEnabled) fetchOpenRouter(globalThis.fetch, this.config.openRouterModelsUrl, 15e3, next.aliases).then((result) => {
|
|
1790
|
+
if (result.error === void 0) this.lastOpenRouter = result;
|
|
1791
|
+
}).catch(() => {});
|
|
1792
|
+
this.recompute(Date.now());
|
|
1793
|
+
this.persist();
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
console.warn(`cost-meter: settings apply failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
/** The read-side payload for the route. */
|
|
1799
|
+
view() {
|
|
1800
|
+
const errors = {};
|
|
1801
|
+
if (this.lastOfficial.error !== void 0) errors.official = this.lastOfficial.error;
|
|
1802
|
+
if (this.lastFxError !== void 0) errors.fx = this.lastFxError;
|
|
1803
|
+
if (this.lastOpenRouter?.error !== void 0) errors.openRouter = this.lastOpenRouter.error;
|
|
1804
|
+
return {
|
|
1805
|
+
currency: this.currency,
|
|
1806
|
+
current: this.currentSnapshot() ?? null,
|
|
1807
|
+
snapshots: this.state.snapshots,
|
|
1808
|
+
overrides: this.state.overrides,
|
|
1809
|
+
aliases: this.state.aliases,
|
|
1810
|
+
cacheReadDiscount: this.state.cacheReadDiscount,
|
|
1811
|
+
fx: this.state.fx,
|
|
1812
|
+
balanceEnabled: this.state.balanceEnabled,
|
|
1813
|
+
openRouterEnabled: this.state.openRouterEnabled,
|
|
1814
|
+
fetchedAt: this.fetchedAt,
|
|
1815
|
+
...Object.keys(errors).length > 0 ? { errors } : {}
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
//#endregion
|
|
1820
|
+
//#region src/session-cost-projection.ts
|
|
1821
|
+
const zeroTotals = () => ({
|
|
1822
|
+
uncachedCost: 0,
|
|
1823
|
+
cacheReadCost: 0,
|
|
1824
|
+
outputCost: 0,
|
|
1825
|
+
cost: 0,
|
|
1826
|
+
pricedSteps: 0,
|
|
1827
|
+
unpricedSteps: 0,
|
|
1828
|
+
steps: 0
|
|
1829
|
+
});
|
|
1830
|
+
/** Wire schema of the projection value (validated before it leaves the host). */
|
|
1831
|
+
const projectionSchema = z.object({
|
|
1832
|
+
model: z.object({
|
|
1833
|
+
provider: z.string(),
|
|
1834
|
+
model: z.string()
|
|
1835
|
+
}).nullable(),
|
|
1836
|
+
steps: z.array(z.object({
|
|
1837
|
+
turn: z.number().int().nonnegative(),
|
|
1838
|
+
step: z.number().int().nonnegative(),
|
|
1839
|
+
time: z.number().int().nonnegative(),
|
|
1840
|
+
model: z.string(),
|
|
1841
|
+
provider: z.string().optional(),
|
|
1842
|
+
uncachedInputTokens: z.number().int().nonnegative(),
|
|
1843
|
+
cacheReadTokens: z.number().int().nonnegative(),
|
|
1844
|
+
cacheWriteTokens: z.number().int().nonnegative(),
|
|
1845
|
+
outputTokens: z.number().int().nonnegative(),
|
|
1846
|
+
uncachedCost: z.number().nonnegative(),
|
|
1847
|
+
cacheReadCost: z.number().nonnegative(),
|
|
1848
|
+
outputCost: z.number().nonnegative(),
|
|
1849
|
+
cost: z.number().nullable(),
|
|
1850
|
+
priced: z.boolean(),
|
|
1851
|
+
unpricedReason: z.enum(["NO_MODEL", "NO_PRICE"]).optional(),
|
|
1852
|
+
snapshotVersion: z.number().int().nonnegative().nullable(),
|
|
1853
|
+
snapshotEffectiveAt: z.number().int().nonnegative().nullable(),
|
|
1854
|
+
band: z.enum([
|
|
1855
|
+
"peak",
|
|
1856
|
+
"offPeak",
|
|
1857
|
+
"single"
|
|
1858
|
+
]).nullable()
|
|
1859
|
+
}).strict()),
|
|
1860
|
+
totals: z.object({
|
|
1861
|
+
uncachedCost: z.number().nonnegative(),
|
|
1862
|
+
cacheReadCost: z.number().nonnegative(),
|
|
1863
|
+
outputCost: z.number().nonnegative(),
|
|
1864
|
+
cost: z.number().nonnegative(),
|
|
1865
|
+
pricedSteps: z.number().int().nonnegative(),
|
|
1866
|
+
unpricedSteps: z.number().int().nonnegative(),
|
|
1867
|
+
steps: z.number().int().nonnegative()
|
|
1868
|
+
}).strict()
|
|
1869
|
+
}).strict();
|
|
1870
|
+
/** Extract the disjoint token buckets from a wire usage object. */
|
|
1871
|
+
function usageBuckets(usage) {
|
|
1872
|
+
return {
|
|
1873
|
+
uncached: usage.inputTokens,
|
|
1874
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
1875
|
+
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
1876
|
+
output: usage.outputTokens
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
function bucketsEmpty(usage) {
|
|
1880
|
+
return usage.uncached === 0 && usage.cacheRead === 0 && usage.cacheWrite === 0 && usage.output === 0;
|
|
1881
|
+
}
|
|
1882
|
+
/** Replace totals with one step, subtracting its predecessor when present. */
|
|
1883
|
+
function replaceTotals(totals, step, previous) {
|
|
1884
|
+
const subtract = (value) => previous === void 0 ? 0 : value;
|
|
1885
|
+
return {
|
|
1886
|
+
uncachedCost: totals.uncachedCost - subtract(previous?.uncachedCost ?? 0) + step.uncachedCost,
|
|
1887
|
+
cacheReadCost: totals.cacheReadCost - subtract(previous?.cacheReadCost ?? 0) + step.cacheReadCost,
|
|
1888
|
+
outputCost: totals.outputCost - subtract(previous?.outputCost ?? 0) + step.outputCost,
|
|
1889
|
+
cost: totals.cost - subtract(previous?.cost ?? 0) + (step.cost ?? 0),
|
|
1890
|
+
pricedSteps: totals.pricedSteps - (previous?.priced === true ? 1 : 0) + (step.priced ? 1 : 0),
|
|
1891
|
+
unpricedSteps: totals.unpricedSteps - (previous?.priced === false ? 1 : 0) + (step.priced ? 0 : 1),
|
|
1892
|
+
steps: totals.steps - (previous === void 0 ? 0 : 1) + 1
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
function sameStep(left, right) {
|
|
1896
|
+
return left.uncachedInputTokens === right.uncachedInputTokens && left.cacheReadTokens === right.cacheReadTokens && left.cacheWriteTokens === right.cacheWriteTokens && left.outputTokens === right.outputTokens && left.cost === right.cost && left.snapshotVersion === right.snapshotVersion && left.band === right.band;
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Fold one committed event onto the ledger. Pure except for synchronous
|
|
1900
|
+
* pricebook mirror reads; returns the same state reference for events the
|
|
1901
|
+
* unit does not own (the framework's zero-work contract).
|
|
1902
|
+
* @param state - the state covering all prior events.
|
|
1903
|
+
* @param event - the next committed session event.
|
|
1904
|
+
* @param pricebook - the pricebook read face.
|
|
1905
|
+
* @returns the next state.
|
|
1906
|
+
*/
|
|
1907
|
+
function foldSessionCost(state, event, pricebook) {
|
|
1908
|
+
if (event.type === "request/header") {
|
|
1909
|
+
const header = canonicalHeader(event.data.header);
|
|
1910
|
+
const model = {
|
|
1911
|
+
provider: header.config.provider,
|
|
1912
|
+
model: header.config.model
|
|
1913
|
+
};
|
|
1914
|
+
if (state.model !== null && state.model.provider === model.provider && state.model.model === model.model) return state;
|
|
1915
|
+
return {
|
|
1916
|
+
...state,
|
|
1917
|
+
model
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
let turn;
|
|
1921
|
+
let step;
|
|
1922
|
+
let usage;
|
|
1923
|
+
if (event.type === "assistant/chunk" && event.data.chunk.type === "usage") {
|
|
1924
|
+
turn = event.data.turn;
|
|
1925
|
+
step = event.data.step;
|
|
1926
|
+
usage = event.data.chunk.usage;
|
|
1927
|
+
} else if (event.type === "assistant/message" && event.data.usage !== void 0) {
|
|
1928
|
+
turn = event.data.turn;
|
|
1929
|
+
step = event.data.step;
|
|
1930
|
+
usage = event.data.usage;
|
|
1931
|
+
} else return state;
|
|
1932
|
+
const buckets = usageBuckets(usage);
|
|
1933
|
+
if (bucketsEmpty(buckets)) return state;
|
|
1934
|
+
const key = `${turn}:${step}`;
|
|
1935
|
+
const previous = state.steps[key];
|
|
1936
|
+
const model = state.model;
|
|
1937
|
+
const hit = model === null ? null : pricebook.priceFor(model.provider, model.model, event.time);
|
|
1938
|
+
const unpricedReason = hit === null ? model === null ? "NO_MODEL" : "NO_PRICE" : void 0;
|
|
1939
|
+
const uncachedCost = hit === null ? 0 : (buckets.uncached + buckets.cacheWrite) * hit.bucket.inputPerMillion / 1e6;
|
|
1940
|
+
const cacheReadCost = hit === null ? 0 : buckets.cacheRead * hit.bucket.cacheReadPerMillion / 1e6;
|
|
1941
|
+
const outputCost = hit === null ? 0 : buckets.output * hit.bucket.outputPerMillion / 1e6;
|
|
1942
|
+
const record = {
|
|
1943
|
+
turn,
|
|
1944
|
+
step,
|
|
1945
|
+
time: event.time,
|
|
1946
|
+
model: model?.model ?? "unknown",
|
|
1947
|
+
...model?.provider === void 0 ? {} : { provider: model.provider },
|
|
1948
|
+
uncachedInputTokens: buckets.uncached,
|
|
1949
|
+
cacheReadTokens: buckets.cacheRead,
|
|
1950
|
+
cacheWriteTokens: buckets.cacheWrite,
|
|
1951
|
+
outputTokens: buckets.output,
|
|
1952
|
+
uncachedCost,
|
|
1953
|
+
cacheReadCost,
|
|
1954
|
+
outputCost,
|
|
1955
|
+
cost: hit === null ? null : uncachedCost + cacheReadCost + outputCost,
|
|
1956
|
+
priced: hit !== null,
|
|
1957
|
+
...unpricedReason === void 0 ? {} : { unpricedReason },
|
|
1958
|
+
snapshotVersion: hit?.snapshotVersion ?? null,
|
|
1959
|
+
snapshotEffectiveAt: hit?.snapshotEffectiveAt ?? null,
|
|
1960
|
+
band: hit?.band ?? null
|
|
1961
|
+
};
|
|
1962
|
+
if (previous !== void 0 && sameStep(previous, record)) return state;
|
|
1963
|
+
return {
|
|
1964
|
+
...state,
|
|
1965
|
+
steps: {
|
|
1966
|
+
...state.steps,
|
|
1967
|
+
[key]: record
|
|
1968
|
+
},
|
|
1969
|
+
totals: replaceTotals(state.totals, record, previous)
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
/** State → wire payload: steps ordered by turn then step. */
|
|
1973
|
+
function viewSessionCost(state) {
|
|
1974
|
+
const steps = Object.values(state.steps).sort((left, right) => left.turn - right.turn || left.step - right.step);
|
|
1975
|
+
return {
|
|
1976
|
+
model: state.model,
|
|
1977
|
+
steps,
|
|
1978
|
+
totals: state.totals
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* The projection unit. The fold reads the pricebook mirror synchronously;
|
|
1983
|
+
* the unit never touches the domain itself.
|
|
1984
|
+
* @param pricebook - the pricebook read face (in-memory mirror).
|
|
1985
|
+
* @returns the definition to register with `ctx.sessionProjections`.
|
|
1986
|
+
*/
|
|
1987
|
+
function sessionCostProjection(pricebook, key) {
|
|
1988
|
+
return {
|
|
1989
|
+
key,
|
|
1990
|
+
schema: projectionSchema,
|
|
1991
|
+
init: () => ({
|
|
1992
|
+
model: null,
|
|
1993
|
+
steps: {},
|
|
1994
|
+
totals: zeroTotals()
|
|
1995
|
+
}),
|
|
1996
|
+
apply: (state, event) => foldSessionCost(state, event, pricebook),
|
|
1997
|
+
view: viewSessionCost,
|
|
1998
|
+
stateVersion: 1
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
//#endregion
|
|
2002
|
+
//#region src/subagent-cost.ts
|
|
2003
|
+
const ZERO_TOTALS = {
|
|
2004
|
+
uncachedCost: 0,
|
|
2005
|
+
cacheReadCost: 0,
|
|
2006
|
+
outputCost: 0,
|
|
2007
|
+
cost: 0,
|
|
2008
|
+
pricedSteps: 0,
|
|
2009
|
+
unpricedSteps: 0,
|
|
2010
|
+
steps: 0
|
|
2011
|
+
};
|
|
2012
|
+
/**
|
|
2013
|
+
* Collect every live descendant session of `rootSessionId` (BFS over the
|
|
2014
|
+
* runtime ownership relation) with its anchored cost totals.
|
|
2015
|
+
* @param rootSessionId - the root conversation's session id.
|
|
2016
|
+
* @param agents - the agents service (live registry).
|
|
2017
|
+
* @param sessions - the sessions service (session store).
|
|
2018
|
+
* @param projections - the sessionProjections service.
|
|
2019
|
+
* @returns one entry per descendant with a priced or unpriced ledger;
|
|
2020
|
+
* empty when the root has no live agent or no descendants.
|
|
2021
|
+
*/
|
|
2022
|
+
function collectSubagentCosts(rootSessionId, agents, sessions, projections, projectionKey = "sessionCost") {
|
|
2023
|
+
const rootAgent = agents.get(rootSessionId);
|
|
2024
|
+
if (rootAgent === void 0) return [];
|
|
2025
|
+
const result = [];
|
|
2026
|
+
const seen = /* @__PURE__ */ new Set([rootSessionId]);
|
|
2027
|
+
const queue = [rootAgent];
|
|
2028
|
+
while (queue.length > 0) {
|
|
2029
|
+
const parent = queue.shift();
|
|
2030
|
+
for (const candidate of agents.list()) {
|
|
2031
|
+
const candidateId = String(candidate.session.id);
|
|
2032
|
+
if (seen.has(candidateId)) continue;
|
|
2033
|
+
if (!agents.isOwnedBy(candidateId, parent)) continue;
|
|
2034
|
+
seen.add(candidateId);
|
|
2035
|
+
queue.push(candidate);
|
|
2036
|
+
const session = sessions.get(candidateId);
|
|
2037
|
+
if (session === void 0) continue;
|
|
2038
|
+
const cost = projections.snapshot(session).values[projectionKey];
|
|
2039
|
+
if (cost === void 0) continue;
|
|
2040
|
+
result.push({
|
|
2041
|
+
sessionId: candidateId,
|
|
2042
|
+
totals: cost.totals ?? { ...ZERO_TOTALS }
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
return result;
|
|
2047
|
+
}
|
|
2048
|
+
/** Sum several totals into one (empty list → the zero totals). */
|
|
2049
|
+
function sumTotals(totals) {
|
|
2050
|
+
const sum = { ...ZERO_TOTALS };
|
|
2051
|
+
for (const totalsEntry of totals) {
|
|
2052
|
+
sum.uncachedCost += totalsEntry.uncachedCost;
|
|
2053
|
+
sum.cacheReadCost += totalsEntry.cacheReadCost;
|
|
2054
|
+
sum.outputCost += totalsEntry.outputCost;
|
|
2055
|
+
sum.cost += totalsEntry.cost;
|
|
2056
|
+
sum.pricedSteps += totalsEntry.pricedSteps;
|
|
2057
|
+
sum.unpricedSteps += totalsEntry.unpricedSteps;
|
|
2058
|
+
sum.steps += totalsEntry.steps;
|
|
2059
|
+
}
|
|
2060
|
+
return sum;
|
|
2061
|
+
}
|
|
2062
|
+
//#endregion
|
|
2063
|
+
//#region src/index.ts
|
|
2064
|
+
/** Stable Cordis plugin name. */
|
|
2065
|
+
const name = "cost-meter";
|
|
2066
|
+
/** Services required before the route and the projection can register. */
|
|
2067
|
+
const inject = ["webServer", "sessionProjections"];
|
|
2068
|
+
/** Public DeepSeek API base; the internal endpoint comes from config `baseURL`. */
|
|
2069
|
+
const PUBLIC_BASE_URL = "https://api.deepseek.com";
|
|
2070
|
+
/** Default credential reference, matching the llm-deepseek adapter. */
|
|
2071
|
+
const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
2072
|
+
/** How long a successful balance snapshot stays fresh (milliseconds). */
|
|
2073
|
+
const DEFAULT_REFRESH_MS = 6e4;
|
|
2074
|
+
/** How long a failed refresh stays cached before retrying (milliseconds). */
|
|
2075
|
+
const FAILED_REFRESH_MS = 1e4;
|
|
2076
|
+
/** Per-request timeout for the provider balance call (milliseconds). */
|
|
2077
|
+
const BALANCE_TIMEOUT_MS = 1e4;
|
|
2078
|
+
/** Default cadence for re-fetching the official pricing page (hours). */
|
|
2079
|
+
const DEFAULT_PRICING_REFRESH_HOURS = 6;
|
|
2080
|
+
/** Max POST body accepted by the settings route (bytes). */
|
|
2081
|
+
const MAX_BODY_BYTES = 262144;
|
|
2082
|
+
const modelPriceSchema = Schema.object({
|
|
2083
|
+
single: Schema.object({
|
|
2084
|
+
cacheReadPerMillion: Schema.number().min(0),
|
|
2085
|
+
inputPerMillion: Schema.number().min(0),
|
|
2086
|
+
outputPerMillion: Schema.number().min(0)
|
|
2087
|
+
}),
|
|
2088
|
+
offPeak: Schema.object({
|
|
2089
|
+
cacheReadPerMillion: Schema.number().min(0),
|
|
2090
|
+
inputPerMillion: Schema.number().min(0),
|
|
2091
|
+
outputPerMillion: Schema.number().min(0)
|
|
2092
|
+
}),
|
|
2093
|
+
peak: Schema.object({
|
|
2094
|
+
cacheReadPerMillion: Schema.number().min(0),
|
|
2095
|
+
inputPerMillion: Schema.number().min(0),
|
|
2096
|
+
outputPerMillion: Schema.number().min(0)
|
|
2097
|
+
})
|
|
2098
|
+
});
|
|
2099
|
+
const Config = Schema.object({
|
|
2100
|
+
apiKeyEnv: Schema.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
|
|
2101
|
+
baseURL: Schema.string().default(PUBLIC_BASE_URL),
|
|
2102
|
+
refreshMs: Schema.natural().min(1e3).default(DEFAULT_REFRESH_MS),
|
|
2103
|
+
pricingRefreshHours: Schema.number().min(1).default(6),
|
|
2104
|
+
trustedHosts: Schema.array(Schema.string()).default([]),
|
|
2105
|
+
fxApiUrl: Schema.string().default(FX_API_URL),
|
|
2106
|
+
defaultFxRate: Schema.number().min(.001).default(DEFAULT_FX_RATE),
|
|
2107
|
+
openRouterModelsUrl: Schema.string().default(OPENROUTER_MODELS_URL),
|
|
2108
|
+
openRouterEnabled: Schema.boolean().default(true),
|
|
2109
|
+
snapshotHistoryLimit: Schema.natural().min(1).default(50),
|
|
2110
|
+
overrides: Schema.dict(modelPriceSchema, Schema.string()).default({}),
|
|
2111
|
+
aliases: Schema.dict(Schema.string(), Schema.string()).default({ ...DEFAULT_ALIASES }),
|
|
2112
|
+
cacheReadDiscount: Schema.number().min(0).max(1).default(DEFAULT_CACHE_READ_DISCOUNT),
|
|
2113
|
+
fxMode: Schema.union(["auto", "manual"]).default("auto"),
|
|
2114
|
+
manualRate: Schema.number().min(.001).default(DEFAULT_FX_RATE),
|
|
2115
|
+
balanceEnabled: Schema.boolean().default(true)
|
|
2116
|
+
});
|
|
2117
|
+
/** Normalized authority of a Host-header value, or undefined when unparsable. */
|
|
2118
|
+
function parseAuthority(authority) {
|
|
2119
|
+
try {
|
|
2120
|
+
return new URL(`http://${authority}`);
|
|
2121
|
+
} catch {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Whether a normalized URL hostname names the local loopback authority.
|
|
2127
|
+
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
|
2128
|
+
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
|
2129
|
+
*/
|
|
2130
|
+
function isLoopbackHostname(hostname) {
|
|
2131
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
2132
|
+
const parts = hostname.split(".");
|
|
2133
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Browser-trust fence for the balance route, mirroring the /api fence: the
|
|
2137
|
+
* Host header is the one header DNS rebinding cannot forge, and loopback or
|
|
2138
|
+
* a declared trusted authority passes.
|
|
2139
|
+
* @param headers - request headers.
|
|
2140
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
2141
|
+
* @returns true when the request Host is ours.
|
|
2142
|
+
*/
|
|
2143
|
+
function isTrustedRequest(headers, trustedHosts) {
|
|
2144
|
+
const host = headers.host;
|
|
2145
|
+
if (typeof host !== "string" || host.length === 0) return false;
|
|
2146
|
+
const hostUrl = parseAuthority(host);
|
|
2147
|
+
if (hostUrl === void 0) return false;
|
|
2148
|
+
if (isLoopbackHostname(hostUrl.hostname)) return true;
|
|
2149
|
+
return trustedHosts.some((entry) => {
|
|
2150
|
+
const entryUrl = parseAuthority(entry);
|
|
2151
|
+
return entryUrl !== void 0 && entryUrl.host === hostUrl.host;
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Resolve the API key for one refresh: the credential seam first, the
|
|
2156
|
+
* process environment as fallback when no seam is composed.
|
|
2157
|
+
* @param ctx - host context.
|
|
2158
|
+
* @param env - the credential reference name.
|
|
2159
|
+
* @returns the resolved key, or undefined when absent everywhere.
|
|
2160
|
+
*/
|
|
2161
|
+
async function resolveApiKey(ctx, env) {
|
|
2162
|
+
const credentials = ctx.get("credentials");
|
|
2163
|
+
if (credentials !== void 0) {
|
|
2164
|
+
const hit = await credentials.resolve(credentialRef(env));
|
|
2165
|
+
if (hit !== void 0 && hit.value.length > 0) return hit.value;
|
|
2166
|
+
}
|
|
2167
|
+
const ambient = process.env[env];
|
|
2168
|
+
return ambient !== void 0 && ambient.length > 0 ? ambient : void 0;
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Parse the first balance row, converting the provider's decimal strings to
|
|
2172
|
+
* numbers.
|
|
2173
|
+
* @param wire - the parsed provider response.
|
|
2174
|
+
* @returns the first row, or null when the response carried no rows.
|
|
2175
|
+
*/
|
|
2176
|
+
function pickBalanceInfo(wire) {
|
|
2177
|
+
const info = wire.balance_infos?.[0];
|
|
2178
|
+
if (info === void 0) return null;
|
|
2179
|
+
return {
|
|
2180
|
+
currency: info.currency,
|
|
2181
|
+
total: Number(info.total_balance),
|
|
2182
|
+
granted: Number(info.granted_balance ?? 0),
|
|
2183
|
+
toppedUp: Number(info.topped_up_balance ?? 0)
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* One provider balance call: resolve the key, GET /user/balance, and fold
|
|
2188
|
+
* the result into a {@link BalanceSnapshot}. Never throws — every failure
|
|
2189
|
+
* becomes an ok:false snapshot.
|
|
2190
|
+
* @param ctx - host context.
|
|
2191
|
+
* @param config - resolved plugin facts.
|
|
2192
|
+
* @returns the folded snapshot.
|
|
2193
|
+
*/
|
|
2194
|
+
async function fetchBalance(ctx, config) {
|
|
2195
|
+
const key = await resolveApiKey(ctx, config.apiKeyEnv);
|
|
2196
|
+
if (key === void 0) return {
|
|
2197
|
+
ok: false,
|
|
2198
|
+
code: "MISSING_CREDENTIAL",
|
|
2199
|
+
message: `cost-meter: no API key for "${config.apiKeyEnv}"; store it through the credentials service or export it in the launching environment`
|
|
2200
|
+
};
|
|
2201
|
+
let response;
|
|
2202
|
+
try {
|
|
2203
|
+
response = await fetch(`${config.baseURL}/user/balance`, {
|
|
2204
|
+
headers: { authorization: `Bearer ${key}` },
|
|
2205
|
+
signal: AbortSignal.timeout(BALANCE_TIMEOUT_MS)
|
|
2206
|
+
});
|
|
2207
|
+
} catch (error) {
|
|
2208
|
+
return {
|
|
2209
|
+
ok: false,
|
|
2210
|
+
code: "NETWORK",
|
|
2211
|
+
message: `cost-meter: balance request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
if (!response.ok) return {
|
|
2215
|
+
ok: false,
|
|
2216
|
+
code: `HTTP_${response.status}`,
|
|
2217
|
+
message: `cost-meter: balance endpoint answered ${response.status} ${response.statusText}`
|
|
2218
|
+
};
|
|
2219
|
+
let wire;
|
|
2220
|
+
try {
|
|
2221
|
+
wire = await response.json();
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
return {
|
|
2224
|
+
ok: false,
|
|
2225
|
+
code: "INVALID_RESPONSE",
|
|
2226
|
+
message: `cost-meter: balance body unparsable: ${error instanceof Error ? error.message : String(error)}`
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
return {
|
|
2230
|
+
ok: true,
|
|
2231
|
+
isAvailable: wire.is_available === true,
|
|
2232
|
+
balance: pickBalanceInfo(wire),
|
|
2233
|
+
fetchedAt: Date.now()
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
/** Read a bounded JSON request body. */
|
|
2237
|
+
async function readBody(req) {
|
|
2238
|
+
const chunks = [];
|
|
2239
|
+
let total = 0;
|
|
2240
|
+
for await (const chunk of req) {
|
|
2241
|
+
const part = chunk;
|
|
2242
|
+
total += part.length;
|
|
2243
|
+
if (total > MAX_BODY_BYTES) throw new Error("body too large");
|
|
2244
|
+
chunks.push(part);
|
|
2245
|
+
}
|
|
2246
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
2247
|
+
if (text.length === 0) return {};
|
|
2248
|
+
return JSON.parse(text);
|
|
2249
|
+
}
|
|
2250
|
+
/** The `cost-meter` settings namespace (plugin configuration page). */
|
|
2251
|
+
const SETTINGS_NAMESPACE = settingsNamespace("cost-meter");
|
|
2252
|
+
/**
|
|
2253
|
+
* Mount the plugin: open the pricebook domain, refresh every remote source,
|
|
2254
|
+
* register the `sessionCost` projection, register the /cost-meter
|
|
2255
|
+
* route (GET view + POST settings/refresh), and keep the balance and the
|
|
2256
|
+
* official pricing fresh.
|
|
2257
|
+
* @param ctx - host context.
|
|
2258
|
+
* @param config - plugin config (schema defaults applied by the Loader).
|
|
2259
|
+
*/
|
|
2260
|
+
async function apply(ctx, config) {
|
|
2261
|
+
const resolved = {
|
|
2262
|
+
apiKeyEnv: config?.apiKeyEnv ?? "DEEPSEEK_API_KEY",
|
|
2263
|
+
baseURL: config?.baseURL ?? "https://api.deepseek.com",
|
|
2264
|
+
refreshMs: config?.refreshMs ?? 6e4,
|
|
2265
|
+
pricingRefreshHours: config?.pricingRefreshHours ?? 6,
|
|
2266
|
+
trustedHosts: config?.trustedHosts ?? [],
|
|
2267
|
+
fxApiUrl: config?.fxApiUrl ?? "https://open.er-api.com/v6/latest/USD",
|
|
2268
|
+
defaultFxRate: config?.defaultFxRate ?? 7.2,
|
|
2269
|
+
openRouterModelsUrl: config?.openRouterModelsUrl ?? "https://openrouter.ai/api/v1/models",
|
|
2270
|
+
openRouterEnabled: config?.openRouterEnabled ?? true,
|
|
2271
|
+
snapshotHistoryLimit: config?.snapshotHistoryLimit ?? 50
|
|
2272
|
+
};
|
|
2273
|
+
const pricebookConfig = {
|
|
2274
|
+
fxApiUrl: resolved.fxApiUrl,
|
|
2275
|
+
defaultFxRate: resolved.defaultFxRate,
|
|
2276
|
+
openRouterModelsUrl: resolved.openRouterModelsUrl,
|
|
2277
|
+
openRouterEnabled: resolved.openRouterEnabled,
|
|
2278
|
+
snapshotHistoryLimit: resolved.snapshotHistoryLimit
|
|
2279
|
+
};
|
|
2280
|
+
const pricebookCny = new PricebookHandle(ctx, pricebookConfig, void 0, "CNY");
|
|
2281
|
+
const pricebookUsd = new PricebookHandle(ctx, pricebookConfig, void 0, "USD");
|
|
2282
|
+
await Promise.all([pricebookCny.init(), pricebookUsd.init()]);
|
|
2283
|
+
await Promise.all([pricebookCny.refresh(), pricebookUsd.refresh()]);
|
|
2284
|
+
ctx.effect(() => () => {
|
|
2285
|
+
pricebookCny.close();
|
|
2286
|
+
pricebookUsd.close();
|
|
2287
|
+
}, "cost-meter: pricebook domains");
|
|
2288
|
+
let currentSettings = () => config ?? {};
|
|
2289
|
+
installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
2290
|
+
setSource: (source) => {
|
|
2291
|
+
currentSettings = source;
|
|
2292
|
+
},
|
|
2293
|
+
onChange: () => {
|
|
2294
|
+
const settings = currentSettings();
|
|
2295
|
+
pricebookCny.applySettings(settings);
|
|
2296
|
+
pricebookUsd.applySettings(settings);
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
ctx.effect(() => ctx.sessionProjections.register(sessionCostProjection(pricebookCny, "sessionCost")), "cost-meter: sessionCost projection");
|
|
2300
|
+
ctx.effect(() => ctx.sessionProjections.register(sessionCostProjection(pricebookUsd, "sessionCostUsd")), "cost-meter: sessionCostUsd projection");
|
|
2301
|
+
let lastBalance;
|
|
2302
|
+
let inflight;
|
|
2303
|
+
const refresh = () => {
|
|
2304
|
+
const now = Date.now();
|
|
2305
|
+
const ttl = lastBalance === void 0 ? 0 : lastBalance.snapshot.ok ? resolved.refreshMs : FAILED_REFRESH_MS;
|
|
2306
|
+
if (lastBalance !== void 0 && now - lastBalance.at < ttl) return Promise.resolve(lastBalance.snapshot);
|
|
2307
|
+
if (inflight === void 0) inflight = fetchBalance(ctx, resolved).then((snapshot) => {
|
|
2308
|
+
lastBalance = {
|
|
2309
|
+
at: Date.now(),
|
|
2310
|
+
snapshot
|
|
2311
|
+
};
|
|
2312
|
+
inflight = void 0;
|
|
2313
|
+
return snapshot;
|
|
2314
|
+
}, (error) => {
|
|
2315
|
+
inflight = void 0;
|
|
2316
|
+
return {
|
|
2317
|
+
ok: false,
|
|
2318
|
+
code: "INTERNAL",
|
|
2319
|
+
message: `cost-meter: ${error instanceof Error ? error.message : String(error)}`
|
|
2320
|
+
};
|
|
2321
|
+
});
|
|
2322
|
+
return inflight;
|
|
2323
|
+
};
|
|
2324
|
+
const pricingTimer = setInterval(() => {
|
|
2325
|
+
pricebookCny.refresh();
|
|
2326
|
+
pricebookUsd.refresh();
|
|
2327
|
+
}, resolved.pricingRefreshHours * 36e5);
|
|
2328
|
+
pricingTimer.unref?.();
|
|
2329
|
+
ctx.effect(() => () => clearInterval(pricingTimer), "cost-meter: pricing refresh timer");
|
|
2330
|
+
const respond = (res, status, body) => {
|
|
2331
|
+
res.writeHead(status, {
|
|
2332
|
+
"content-type": "application/json; charset=utf-8",
|
|
2333
|
+
"cache-control": "no-store"
|
|
2334
|
+
});
|
|
2335
|
+
res.end(JSON.stringify(body));
|
|
2336
|
+
};
|
|
2337
|
+
/** Parse the `session` query parameter (the requesting session id). */
|
|
2338
|
+
const sessionOf = (req) => {
|
|
2339
|
+
try {
|
|
2340
|
+
const value = new URL(req.url ?? "/", "http://localhost").searchParams.get("session");
|
|
2341
|
+
return value === null || value.length === 0 ? void 0 : value;
|
|
2342
|
+
} catch {
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
/** Parse the `currency` query parameter; defaults to CNY. */
|
|
2347
|
+
const currencyOf = (req) => {
|
|
2348
|
+
try {
|
|
2349
|
+
return new URL(req.url ?? "/", "http://localhost").searchParams.get("currency") === "USD" ? "USD" : "CNY";
|
|
2350
|
+
} catch {
|
|
2351
|
+
return "CNY";
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
const buildResponse = async (rootSessionId, currency) => {
|
|
2355
|
+
const agents = ctx.get("agents");
|
|
2356
|
+
const sessionsStore = ctx.get("sessions");
|
|
2357
|
+
const pricebook = currency === "USD" ? pricebookUsd : pricebookCny;
|
|
2358
|
+
const projectionKey = currency === "USD" ? "sessionCostUsd" : "sessionCost";
|
|
2359
|
+
const subagents = rootSessionId === void 0 || agents === void 0 || sessionsStore === void 0 ? [] : collectSubagentCosts(rootSessionId, agents, sessionsStore, ctx.sessionProjections, projectionKey);
|
|
2360
|
+
return {
|
|
2361
|
+
balance: await refresh(),
|
|
2362
|
+
pricebook: pricebook.view(),
|
|
2363
|
+
subagents
|
|
2364
|
+
};
|
|
2365
|
+
};
|
|
2366
|
+
const route = {
|
|
2367
|
+
kind: "exact",
|
|
2368
|
+
path: "/cost-meter",
|
|
2369
|
+
handler: async (req, res) => {
|
|
2370
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
2371
|
+
res.writeHead(405, { allow: "GET, POST" });
|
|
2372
|
+
res.end("method not allowed");
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
if (!isTrustedRequest(req.headers, resolved.trustedHosts)) {
|
|
2376
|
+
res.writeHead(403);
|
|
2377
|
+
res.end("forbidden");
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
if (req.method === "POST") {
|
|
2381
|
+
try {
|
|
2382
|
+
const body = await readBody(req);
|
|
2383
|
+
if (body.action !== "refresh") throw new Error(`unknown action "${String(body.action)}"`);
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
respond(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
try {
|
|
2389
|
+
await Promise.all([pricebookCny.refresh(), pricebookUsd.refresh()]);
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
respond(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
respond(res, 200, await buildResponse(sessionOf(req), currencyOf(req)));
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
respond(res, 200, await buildResponse(sessionOf(req), currencyOf(req)));
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
ctx.effect(() => ctx.webServer.register(route), "cost-meter: /cost-meter route");
|
|
2401
|
+
}
|
|
2402
|
+
//#endregion
|
|
2403
|
+
export { Config, DEFAULT_ALIASES, DEFAULT_API_KEY_ENV, DEFAULT_CACHE_READ_DISCOUNT, DEFAULT_FX_RATE, DEFAULT_PRICING_REFRESH_HOURS, DEFAULT_REFRESH_MS, DEFAULT_SNAPSHOT_HISTORY_LIMIT, FALLBACK_CURRENT, FALLBACK_CURRENT_USD, FALLBACK_PEAK, FALLBACK_PEAK_USD, FX_API_URL, OPENROUTER_MODELS_URL, PEAK_PRICING_START_MS, PRICEBOOK_DOMAIN, PRICEBOOK_DOMAIN_USD, PRICING_URL, PRICING_URL_EN, PUBLIC_BASE_URL, PricebookHandle, SETTINGS_NAMESPACE, apply, bandForTime, bucketOf, collectSubagentCosts, computePricebook, effectiveBucket, effectivePricing, effectiveRate, fetchBalance, fetchFxRate, fetchOpenRouter, fetchPricing, foldSessionCost, initialPricebookState, inject, isLoopbackHostname, isPeakHour, isTrustedRequest, modelKeys, name, officialInputOf, parseCurrentTable, parseCurrentTableEn, parsePeakTable, parsePeakTableEn, pickBalanceInfo, priceAt, pricesEqual, pricingKeyOfModel, resolveApiKey, sessionCostProjection, snapshotForTime, stepCost, stripHtml, sumTotals, usageBuckets, viewSessionCost };
|