dsh-session-fee 0.0.0-stage → 0.4.3
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/PRICES.md +79 -0
- package/README.md +205 -2
- package/client.js +379 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +9 -0
- package/index.js +87 -0
- package/lib/catalog.js +228 -0
- package/lib/config.js +68 -0
- package/lib/pricing.js +405 -0
- package/lib/projection.js +295 -0
- package/lib/reload.js +68 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -4
- package/prices.example.json +71 -0
- package/prices.json +213 -0
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
const DEFAULT_TIME_ZONE = "Asia/Shanghai";
|
|
2
|
+
const DEFAULT_UNIT = 1_000_000;
|
|
3
|
+
const PRICE_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
|
|
4
|
+
const localFormatters = new Map();
|
|
5
|
+
const globCache = new Map();
|
|
6
|
+
|
|
7
|
+
function fail(path, message) {
|
|
8
|
+
throw new Error(`Invalid session-cost price book at ${path}: ${message}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function object(value, path, allowedKeys = null) {
|
|
12
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "expected an object");
|
|
13
|
+
if (allowedKeys !== null) {
|
|
14
|
+
const allowed = new Set(allowedKeys);
|
|
15
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) fail(path, `unknown key "${key}"`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function text(value, path) {
|
|
21
|
+
if (typeof value !== "string" || value.trim() === "") fail(path, "expected a non-empty string");
|
|
22
|
+
return value.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function number(value, path, optional = false) {
|
|
26
|
+
if (value === void 0 && optional) return void 0;
|
|
27
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) fail(path, "expected a finite non-negative number");
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function array(value, path) {
|
|
32
|
+
if (!Array.isArray(value)) fail(path, "expected an array");
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validDate(value, path) {
|
|
37
|
+
const source = text(value, path);
|
|
38
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(source);
|
|
39
|
+
if (match === null) fail(path, "expected YYYY-MM-DD");
|
|
40
|
+
const year = Number(match[1]);
|
|
41
|
+
const month = Number(match[2]);
|
|
42
|
+
const day = Number(match[3]);
|
|
43
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
44
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) fail(path, "invalid calendar date");
|
|
45
|
+
return source;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function instant(value, path) {
|
|
49
|
+
const source = text(value, path);
|
|
50
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/.exec(source);
|
|
51
|
+
if (match === null) fail(path, "expected YYYY-MM-DDTHH:mm:ss with Z or an explicit offset");
|
|
52
|
+
validDate(match[1], path);
|
|
53
|
+
const hour = Number(match[2]);
|
|
54
|
+
const minute = Number(match[3]);
|
|
55
|
+
const second = match[4] === void 0 ? 0 : Number(match[4]);
|
|
56
|
+
if (hour > 23 || minute > 59 || second > 59) fail(path, "invalid ISO time of day");
|
|
57
|
+
if (match[5] !== "Z") {
|
|
58
|
+
const offset = /^[+-](\d{2}):(\d{2})$/.exec(match[5]);
|
|
59
|
+
if (Number(offset[1]) > 23 || Number(offset[2]) > 59) fail(path, "invalid ISO offset");
|
|
60
|
+
}
|
|
61
|
+
const milliseconds = Date.parse(source);
|
|
62
|
+
if (!Number.isFinite(milliseconds)) fail(path, "invalid ISO date-time");
|
|
63
|
+
return milliseconds;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clock(value, path, allowEndOfDay = false) {
|
|
67
|
+
const source = text(value, path);
|
|
68
|
+
if (allowEndOfDay && source === "24:00") return 1440;
|
|
69
|
+
const match = /^(\d{2}):(\d{2})$/.exec(source);
|
|
70
|
+
if (match === null) fail(path, "expected HH:mm");
|
|
71
|
+
const hour = Number(match[1]);
|
|
72
|
+
const minute = Number(match[2]);
|
|
73
|
+
if (hour > 23 || minute > 59) fail(path, "invalid local time");
|
|
74
|
+
return hour * 60 + minute;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function timeZone(value, path) {
|
|
78
|
+
const zone = value === void 0 ? DEFAULT_TIME_ZONE : text(value, path);
|
|
79
|
+
try {
|
|
80
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone }).format(0);
|
|
81
|
+
} catch {
|
|
82
|
+
fail(path, `unknown IANA time zone ${zone}`);
|
|
83
|
+
}
|
|
84
|
+
return zone;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** "YYYY-MM-DD" or "YYYY-MM-DD..YYYY-MM-DD" → one inclusive [start, end] range. */
|
|
88
|
+
function dateRange(value, path) {
|
|
89
|
+
const source = text(value, path);
|
|
90
|
+
const [start, end = start, extra] = source.split("..");
|
|
91
|
+
if (extra !== void 0) fail(path, "expected YYYY-MM-DD or YYYY-MM-DD..YYYY-MM-DD");
|
|
92
|
+
validDate(start, path);
|
|
93
|
+
validDate(end, path);
|
|
94
|
+
if (end < start) fail(path, "range end is earlier than its start");
|
|
95
|
+
return [start, end];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeDateSets(input) {
|
|
99
|
+
const sets = new Map();
|
|
100
|
+
if (input === void 0) return sets;
|
|
101
|
+
object(input, "priceBook.dateSets");
|
|
102
|
+
for (const [name, list] of Object.entries(input)) {
|
|
103
|
+
const path = `priceBook.dateSets.${name}`;
|
|
104
|
+
if (!/^[\w.-]+$/.test(name)) fail(path, "set names may use letters, digits, _, . and -");
|
|
105
|
+
sets.set(name, array(list, path).map((entry, index) => dateRange(entry, `${path}[${index}]`)));
|
|
106
|
+
}
|
|
107
|
+
return sets;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Date lists accept single dates, inclusive ranges and "@name" references to dateSets. */
|
|
111
|
+
function dateList(value, path, dateSets) {
|
|
112
|
+
if (value === void 0) return null;
|
|
113
|
+
const ranges = [];
|
|
114
|
+
array(value, path).forEach((entry, index) => {
|
|
115
|
+
const itemPath = `${path}[${index}]`;
|
|
116
|
+
const source = text(entry, itemPath);
|
|
117
|
+
if (source.startsWith("@")) {
|
|
118
|
+
const set = dateSets.get(source.slice(1));
|
|
119
|
+
if (set === void 0) fail(itemPath, `unknown date set ${source}`);
|
|
120
|
+
ranges.push(...set);
|
|
121
|
+
} else {
|
|
122
|
+
ranges.push(dateRange(source, itemPath));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return ranges;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeWhen(input, path, defaultZone, dateSets) {
|
|
129
|
+
if (input === void 0) return null;
|
|
130
|
+
const value = object(input, path, ["timeZone", "from", "to", "dates", "excludeDates", "weekdays", "intervals"]);
|
|
131
|
+
const zone = value.timeZone === void 0 ? defaultZone : timeZone(value.timeZone, `${path}.timeZone`);
|
|
132
|
+
const fromMs = value.from === void 0 ? null : instant(value.from, `${path}.from`);
|
|
133
|
+
const toMs = value.to === void 0 ? null : instant(value.to, `${path}.to`);
|
|
134
|
+
if (fromMs !== null && toMs !== null && fromMs >= toMs) fail(path, "from must be earlier than to");
|
|
135
|
+
const dates = dateList(value.dates, `${path}.dates`, dateSets);
|
|
136
|
+
const excludeDates = dateList(value.excludeDates, `${path}.excludeDates`, dateSets);
|
|
137
|
+
const weekdays = value.weekdays === void 0 ? null : array(value.weekdays, `${path}.weekdays`).map((entry, index) => {
|
|
138
|
+
if (!Number.isInteger(entry) || entry < 1 || entry > 7) fail(`${path}.weekdays[${index}]`, "expected ISO weekday 1 (Monday) through 7 (Sunday)");
|
|
139
|
+
return entry;
|
|
140
|
+
});
|
|
141
|
+
const intervals = value.intervals === void 0 ? null : array(value.intervals, `${path}.intervals`).map((entry, index) => {
|
|
142
|
+
const item = object(entry, `${path}.intervals[${index}]`, ["start", "end"]);
|
|
143
|
+
const start = clock(item.start, `${path}.intervals[${index}].start`);
|
|
144
|
+
const end = clock(item.end, `${path}.intervals[${index}].end`, true);
|
|
145
|
+
if (start === end) fail(`${path}.intervals[${index}]`, "start and end must differ");
|
|
146
|
+
return { start, end };
|
|
147
|
+
});
|
|
148
|
+
return { timeZone: zone, fromMs, toMs, dates, excludeDates, weekdays, intervals };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Tier prices replace the base prices once the prompt (input + cache read + cache write) exceeds `above`. */
|
|
152
|
+
function normalizeTiers(input, path, base) {
|
|
153
|
+
if (input === void 0) return [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const tiers = array(input, path).map((entry, index) => {
|
|
156
|
+
const tierPath = `${path}[${index}]`;
|
|
157
|
+
const tier = object(entry, tierPath, ["above", ...PRICE_KEYS]);
|
|
158
|
+
const above = number(tier.above, `${tierPath}.above`);
|
|
159
|
+
if (!Number.isInteger(above) || above === 0) fail(`${tierPath}.above`, "expected a positive integer token count");
|
|
160
|
+
if (seen.has(above)) fail(`${tierPath}.above`, `duplicate tier threshold ${above}`);
|
|
161
|
+
seen.add(above);
|
|
162
|
+
const prices = { above };
|
|
163
|
+
for (const key of PRICE_KEYS) prices[key] = number(tier[key], `${tierPath}.${key}`, true) ?? base[key];
|
|
164
|
+
return prices;
|
|
165
|
+
});
|
|
166
|
+
return tiers.sort((left, right) => left.above - right.above);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeRule(entry, path, index, zone, dateSets) {
|
|
170
|
+
const rule = object(entry, path, ["name", "note", "currency", "unit", ...PRICE_KEYS, "tiers", "when"]);
|
|
171
|
+
const unit = rule.unit === void 0 ? DEFAULT_UNIT : number(rule.unit, `${path}.unit`);
|
|
172
|
+
if (unit === 0) fail(`${path}.unit`, "must be greater than zero");
|
|
173
|
+
const currency = text(rule.currency, `${path}.currency`).toUpperCase();
|
|
174
|
+
if (!/^[A-Z]{3}$/.test(currency)) fail(`${path}.currency`, "expected a three-letter ISO currency code");
|
|
175
|
+
const base = {
|
|
176
|
+
input: number(rule.input, `${path}.input`),
|
|
177
|
+
output: number(rule.output, `${path}.output`),
|
|
178
|
+
cacheRead: number(rule.cacheRead, `${path}.cacheRead`, true),
|
|
179
|
+
cacheWrite: number(rule.cacheWrite, `${path}.cacheWrite`, true)
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
name: rule.name === void 0 ? `Rule ${index + 1}` : text(rule.name, `${path}.name`),
|
|
183
|
+
currency,
|
|
184
|
+
unit,
|
|
185
|
+
...base,
|
|
186
|
+
tiers: normalizeTiers(rule.tiers, `${path}.tiers`, base),
|
|
187
|
+
when: normalizeWhen(rule.when, `${path}.when`, zone, dateSets)
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** "provider/model": provider is exact or "*", model may use "*" globs; both compare case-insensitively. */
|
|
192
|
+
function routePattern(value, path, allowWildcard = true) {
|
|
193
|
+
const source = text(value, path);
|
|
194
|
+
const slash = source.indexOf("/");
|
|
195
|
+
if (slash <= 0 || slash === source.length - 1) fail(path, 'expected "provider/model"');
|
|
196
|
+
const provider = source.slice(0, slash).toLowerCase();
|
|
197
|
+
const model = source.slice(slash + 1).toLowerCase();
|
|
198
|
+
if (provider.includes("*") && provider !== "*") fail(path, 'provider must be exact or "*"');
|
|
199
|
+
const wildcard = provider === "*" || model.includes("*");
|
|
200
|
+
if (wildcard && !allowWildcard) fail(path, "wildcards are not allowed here");
|
|
201
|
+
return { source, provider, model, wildcard };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validate and normalize the user-owned JSON price book. Rules inside one model are first-match. */
|
|
205
|
+
export function normalizePriceBook(input) {
|
|
206
|
+
const root = object(input, "priceBook", ["version", "note", "timeZone", "dateSets", "models"]);
|
|
207
|
+
if (root.version === 1) fail("priceBook.version", 'version 1 is no longer supported: rewrite each "provider" + "model" pair as a "match" list (see README)');
|
|
208
|
+
if (root.version !== 2) fail("priceBook.version", "only version 2 is supported");
|
|
209
|
+
const defaultZone = timeZone(root.timeZone, "priceBook.timeZone");
|
|
210
|
+
const dateSets = normalizeDateSets(root.dateSets);
|
|
211
|
+
const claimed = new Map();
|
|
212
|
+
const entries = array(root.models, "priceBook.models").map((entry, modelIndex) => {
|
|
213
|
+
const path = `priceBook.models[${modelIndex}]`;
|
|
214
|
+
const model = object(entry, path, ["name", "note", "match", "rules", "catalog"]);
|
|
215
|
+
const patterns = array(model.match, `${path}.match`).map((item, index) => routePattern(item, `${path}.match[${index}]`));
|
|
216
|
+
if (patterns.length === 0) fail(`${path}.match`, "expected at least one route");
|
|
217
|
+
for (const pattern of patterns) {
|
|
218
|
+
const key = `${pattern.provider}/${pattern.model}`;
|
|
219
|
+
if (claimed.has(key)) fail(path, `route ${pattern.source} is already claimed by ${claimed.get(key)}`);
|
|
220
|
+
claimed.set(key, path);
|
|
221
|
+
}
|
|
222
|
+
if ((model.rules === void 0) === (model.catalog === void 0)) fail(path, 'expected exactly one of "rules" or "catalog"');
|
|
223
|
+
const name = model.name === void 0 ? patterns[0].source : text(model.name, `${path}.name`);
|
|
224
|
+
if (model.catalog !== void 0) {
|
|
225
|
+
const target = routePattern(model.catalog, `${path}.catalog`, false);
|
|
226
|
+
return { name, patterns, rules: null, catalog: { provider: target.provider, model: target.model } };
|
|
227
|
+
}
|
|
228
|
+
const rules = array(model.rules, `${path}.rules`).map((item, ruleIndex) => normalizeRule(item, `${path}.rules[${ruleIndex}]`, ruleIndex, defaultZone, dateSets));
|
|
229
|
+
return { name, patterns, rules, catalog: null };
|
|
230
|
+
});
|
|
231
|
+
return { version: 2, timeZone: defaultZone, entries };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function formatter(zone) {
|
|
235
|
+
let result = localFormatters.get(zone);
|
|
236
|
+
if (result === void 0) {
|
|
237
|
+
result = new Intl.DateTimeFormat("en-CA", {
|
|
238
|
+
timeZone: zone,
|
|
239
|
+
year: "numeric",
|
|
240
|
+
month: "2-digit",
|
|
241
|
+
day: "2-digit",
|
|
242
|
+
hour: "2-digit",
|
|
243
|
+
minute: "2-digit",
|
|
244
|
+
hourCycle: "h23"
|
|
245
|
+
});
|
|
246
|
+
localFormatters.set(zone, result);
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function weekday(date) {
|
|
252
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
253
|
+
return new Date(Date.UTC(year, month - 1, day)).getUTCDay() || 7;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function shiftDate(date, days) {
|
|
257
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
258
|
+
const shifted = new Date(Date.UTC(year, month - 1, day + days));
|
|
259
|
+
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, "0")}-${String(shifted.getUTCDate()).padStart(2, "0")}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function localParts(milliseconds, zone) {
|
|
263
|
+
const parts = {};
|
|
264
|
+
for (const part of formatter(zone).formatToParts(new Date(milliseconds))) {
|
|
265
|
+
if (part.type !== "literal") parts[part.type] = part.value;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
date: `${parts.year}-${parts.month}-${parts.day}`,
|
|
269
|
+
minutes: Number(parts.hour) * 60 + Number(parts.minute)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const inRanges = (ranges, date) => ranges.some(([start, end]) => start <= date && date <= end);
|
|
274
|
+
|
|
275
|
+
function dateAllowed(when, date) {
|
|
276
|
+
if (when.dates !== null && !inRanges(when.dates, date)) return false;
|
|
277
|
+
if (when.excludeDates !== null && inRanges(when.excludeDates, date)) return false;
|
|
278
|
+
if (when.weekdays !== null && !when.weekdays.includes(weekday(date))) return false;
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function localTimeAllowed(when, milliseconds) {
|
|
283
|
+
const local = localParts(milliseconds, when.timeZone);
|
|
284
|
+
if (when.intervals === null) return dateAllowed(when, local.date);
|
|
285
|
+
for (const interval of when.intervals) {
|
|
286
|
+
if (interval.start < interval.end) {
|
|
287
|
+
if (local.minutes >= interval.start && local.minutes < interval.end && dateAllowed(when, local.date)) return true;
|
|
288
|
+
} else {
|
|
289
|
+
if (local.minutes >= interval.start && dateAllowed(when, local.date)) return true;
|
|
290
|
+
if (local.minutes < interval.end && dateAllowed(when, shiftDate(local.date, -1))) return true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function whenMatches(when, milliseconds) {
|
|
297
|
+
if (when === null) return true;
|
|
298
|
+
if (when.fromMs !== null && milliseconds < when.fromMs) return false;
|
|
299
|
+
if (when.toMs !== null && milliseconds >= when.toMs) return false;
|
|
300
|
+
return localTimeAllowed(when, milliseconds);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function globMatches(glob, value) {
|
|
304
|
+
let regex = globCache.get(glob);
|
|
305
|
+
if (regex === void 0) {
|
|
306
|
+
regex = new RegExp(`^${glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`);
|
|
307
|
+
globCache.set(glob, regex);
|
|
308
|
+
}
|
|
309
|
+
return regex.test(value);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function patternMatches(pattern, provider, model) {
|
|
313
|
+
if (pattern.provider !== "*" && pattern.provider !== provider) return false;
|
|
314
|
+
return pattern.model.includes("*") ? globMatches(pattern.model, model) : pattern.model === model;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function compactTokens(value) {
|
|
318
|
+
if (value >= 1_000_000 && value % 1_000_000 === 0) return `${value / 1_000_000}M`;
|
|
319
|
+
if (value >= 1000 && value % 1000 === 0) return `${value / 1000}K`;
|
|
320
|
+
return String(value);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** "above 272000" → ">272K"; "above 199999" (i.e. from 200K on) → "≥200K". */
|
|
324
|
+
function tierLabel(above) {
|
|
325
|
+
const next = compactTokens(above + 1);
|
|
326
|
+
return /[KM]$/.test(next) && !/[KM]$/.test(compactTokens(above)) ? `≥${next}` : `>${compactTokens(above)}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Bind one validated price book and an optional model catalog into a pricer.
|
|
331
|
+
* Resolution order: exact routes in the book, then wildcard routes in file
|
|
332
|
+
* order, then the catalog fallback. Results are cached per route.
|
|
333
|
+
*/
|
|
334
|
+
export function createPricer({ book, catalog = null, addReasoningToOutput = [] }) {
|
|
335
|
+
const resolved = new Map();
|
|
336
|
+
const reasoningProviders = new Set(addReasoningToOutput.map((provider) => provider.toLowerCase()));
|
|
337
|
+
|
|
338
|
+
function fromEntry(entry) {
|
|
339
|
+
if (entry.catalog === null) return { kind: "book", name: entry.name, rules: entry.rules };
|
|
340
|
+
const hit = catalog?.lookup(entry.catalog.provider, entry.catalog.model) ?? null;
|
|
341
|
+
if (hit === null) return null;
|
|
342
|
+
return { kind: "book-catalog", name: entry.name, rules: [{ ...hit.rule, name: `${entry.name} → ${hit.rule.name}` }] };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function lookup(route) {
|
|
346
|
+
const provider = route.provider.toLowerCase();
|
|
347
|
+
const model = route.model.toLowerCase();
|
|
348
|
+
for (const entry of book.entries) {
|
|
349
|
+
if (entry.patterns.some((pattern) => !pattern.wildcard && patternMatches(pattern, provider, model))) return fromEntry(entry);
|
|
350
|
+
}
|
|
351
|
+
for (const entry of book.entries) {
|
|
352
|
+
if (entry.patterns.some((pattern) => pattern.wildcard && patternMatches(pattern, provider, model))) return fromEntry(entry);
|
|
353
|
+
}
|
|
354
|
+
const hit = catalog?.find(route.provider, route.model) ?? null;
|
|
355
|
+
return hit === null ? null : { kind: "catalog", name: hit.rule.name, rules: [hit.rule] };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function resolve(route) {
|
|
359
|
+
if (route === null) return null;
|
|
360
|
+
const key = `${route.provider}\0${route.model}`;
|
|
361
|
+
if (!resolved.has(key)) resolved.set(key, lookup(route));
|
|
362
|
+
return resolved.get(key);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Price one provider-reported attempt. Missing routes, rules, or a non-zero
|
|
367
|
+
* bucket without a price return null; a zero price is an exact zero cost.
|
|
368
|
+
*/
|
|
369
|
+
function price(route, usage, milliseconds) {
|
|
370
|
+
const source = resolve(route);
|
|
371
|
+
if (source === null) return null;
|
|
372
|
+
const rule = source.rules.find((candidate) => whenMatches(candidate.when, milliseconds));
|
|
373
|
+
if (rule === void 0) return null;
|
|
374
|
+
const outputTokens = usage.outputTokens + (reasoningProviders.has(route.provider.toLowerCase()) ? usage.reasoningTokens ?? 0 : 0);
|
|
375
|
+
const promptTokens = usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
376
|
+
let rates = rule;
|
|
377
|
+
let tier = null;
|
|
378
|
+
for (const candidate of rule.tiers) {
|
|
379
|
+
if (promptTokens > candidate.above) {
|
|
380
|
+
rates = candidate;
|
|
381
|
+
tier = candidate;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
let amount = 0;
|
|
385
|
+
for (const [tokens, unitPrice] of [
|
|
386
|
+
[usage.uncachedInputTokens, rates.input],
|
|
387
|
+
[outputTokens, rates.output],
|
|
388
|
+
[usage.cacheReadTokens, rates.cacheRead],
|
|
389
|
+
[usage.cacheWriteTokens, rates.cacheWrite]
|
|
390
|
+
]) {
|
|
391
|
+
if (tokens === 0) continue;
|
|
392
|
+
if (unitPrice === void 0) return null;
|
|
393
|
+
amount += tokens / rule.unit * unitPrice;
|
|
394
|
+
}
|
|
395
|
+
if (!Number.isFinite(amount)) return null;
|
|
396
|
+
return {
|
|
397
|
+
currency: rule.currency,
|
|
398
|
+
amount,
|
|
399
|
+
rule: tier === null ? rule.name : `${rule.name} · ${tierLabel(tier.above)}`,
|
|
400
|
+
source: source.kind
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { resolve, price };
|
|
405
|
+
}
|