opencode-token-tracker 1.5.5 → 1.5.7
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/dist/bin/opencode-tokens.js +18 -6
- package/dist/index.js +144 -32
- package/dist/lib/shared.d.ts +1 -1
- package/dist/lib/shared.js +13 -7
- package/dist/test/shared.test.js +79 -0
- package/package.json +1 -1
|
@@ -325,17 +325,29 @@ function cmdModels() {
|
|
|
325
325
|
console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)}`);
|
|
326
326
|
console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
|
|
327
327
|
for (const { model, provider, count } of sorted) {
|
|
328
|
-
let status
|
|
328
|
+
let status;
|
|
329
|
+
// Mirror the runtime pricing resolution order (getModelPricing in index.ts)
|
|
329
330
|
if (config.providers?.[provider]) {
|
|
330
331
|
status = "provider cfg";
|
|
331
332
|
}
|
|
332
|
-
else if (findModelConfigPricing(config.models, model, provider)) {
|
|
333
|
+
else if (findModelConfigPricing(config.models, model, provider, false)) {
|
|
333
334
|
status = "model cfg";
|
|
334
335
|
}
|
|
335
|
-
else if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
336
|
+
else if (BUILTIN_PRICING[model]) {
|
|
337
|
+
status = "built-in";
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
const modelLower = model.toLowerCase();
|
|
341
|
+
const hasBuiltinPartial = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && modelLower.includes(k.toLowerCase()));
|
|
342
|
+
if (hasBuiltinPartial) {
|
|
343
|
+
status = "built-in";
|
|
344
|
+
}
|
|
345
|
+
else if (findModelConfigPricing(config.models, model, provider, true)) {
|
|
346
|
+
status = "model cfg";
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
status = "default";
|
|
350
|
+
}
|
|
339
351
|
}
|
|
340
352
|
console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)}`);
|
|
341
353
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
3
|
+
import { open } from "fs/promises";
|
|
3
4
|
import { join } from "path";
|
|
4
5
|
import { homedir } from "os";
|
|
5
6
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
@@ -57,8 +58,8 @@ function getModelPricing(model, provider) {
|
|
|
57
58
|
if (config.providers[provider]) {
|
|
58
59
|
return config.providers[provider];
|
|
59
60
|
}
|
|
60
|
-
// 2. Check user-defined model pricing
|
|
61
|
-
const configuredPricing = findModelConfigPricing(config.models, model, provider);
|
|
61
|
+
// 2. Check user-defined model pricing (exact match only)
|
|
62
|
+
const configuredPricing = findModelConfigPricing(config.models, model, provider, false);
|
|
62
63
|
if (configuredPricing) {
|
|
63
64
|
return configuredPricing;
|
|
64
65
|
}
|
|
@@ -68,12 +69,17 @@ function getModelPricing(model, provider) {
|
|
|
68
69
|
}
|
|
69
70
|
// 4. Try partial match in built-in pricing
|
|
70
71
|
const modelLower = model.toLowerCase();
|
|
71
|
-
for (const [key, pricing] of Object.entries(BUILTIN_PRICING)) {
|
|
72
|
+
for (const [key, pricing] of Object.entries(BUILTIN_PRICING).sort(([a], [b]) => b.length - a.length)) {
|
|
72
73
|
if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
|
|
73
74
|
return pricing;
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
|
-
// 5.
|
|
77
|
+
// 5. Try partial match in user config
|
|
78
|
+
const partialUserPricing = findModelConfigPricing(config.models, model, provider, true);
|
|
79
|
+
if (partialUserPricing) {
|
|
80
|
+
return partialUserPricing;
|
|
81
|
+
}
|
|
82
|
+
// 6. Fallback to default
|
|
77
83
|
return BUILTIN_PRICING["_default"];
|
|
78
84
|
}
|
|
79
85
|
export function getProviderFamily(model, provider) {
|
|
@@ -191,29 +197,74 @@ const budgetTracker = {
|
|
|
191
197
|
function loadCostsSince(since) {
|
|
192
198
|
if (!existsSync(LOG_FILE))
|
|
193
199
|
return 0;
|
|
200
|
+
let total = 0;
|
|
201
|
+
let fd = null;
|
|
194
202
|
try {
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
fd = openSync(LOG_FILE, "r");
|
|
204
|
+
const stat = statSync(LOG_FILE);
|
|
205
|
+
const fileSize = stat.size;
|
|
206
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
207
|
+
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
208
|
+
let filePos = fileSize;
|
|
209
|
+
let leftover = "";
|
|
210
|
+
let shouldStop = false;
|
|
211
|
+
while (filePos > 0 && !shouldStop) {
|
|
212
|
+
const readLength = Math.min(CHUNK_SIZE, filePos);
|
|
213
|
+
filePos -= readLength;
|
|
214
|
+
readSync(fd, buffer, 0, readLength, filePos);
|
|
215
|
+
const chunkStr = buffer.toString("utf8", 0, readLength) + leftover;
|
|
216
|
+
const lines = chunkStr.split("\n");
|
|
217
|
+
// The leftmost line could be cut off, save it for the next chunk read to the left
|
|
218
|
+
leftover = lines[0];
|
|
219
|
+
// Iterate lines in reverse order (from end to start)
|
|
220
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
221
|
+
const line = lines[i].trim();
|
|
222
|
+
if (!line)
|
|
223
|
+
continue;
|
|
224
|
+
try {
|
|
225
|
+
const entry = JSON.parse(line);
|
|
226
|
+
if (entry.type !== "tokens" || !entry.cost)
|
|
227
|
+
continue;
|
|
228
|
+
if (entry._ts < since) {
|
|
229
|
+
shouldStop = true;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
total += entry.cost;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Skip malformed lines
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Include the very first line at the top
|
|
240
|
+
if (!shouldStop && leftover.trim()) {
|
|
199
241
|
try {
|
|
200
|
-
const entry = JSON.parse(
|
|
201
|
-
if (entry.type === "tokens" && entry._ts >= since
|
|
242
|
+
const entry = JSON.parse(leftover.trim());
|
|
243
|
+
if (entry.type === "tokens" && entry.cost && entry._ts >= since) {
|
|
202
244
|
total += entry.cost;
|
|
203
245
|
}
|
|
204
246
|
}
|
|
205
247
|
catch { }
|
|
206
248
|
}
|
|
207
|
-
return total;
|
|
208
249
|
}
|
|
209
250
|
catch {
|
|
210
|
-
|
|
251
|
+
// 异常路径下放弃部分累加结果,与 1.5.5 之前的语义保持一致,避免下游基于偏小值做预算判断
|
|
252
|
+
total = 0;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
if (fd !== null) {
|
|
256
|
+
try {
|
|
257
|
+
closeSync(fd);
|
|
258
|
+
}
|
|
259
|
+
catch { }
|
|
260
|
+
}
|
|
211
261
|
}
|
|
262
|
+
return total;
|
|
212
263
|
}
|
|
213
264
|
/**
|
|
214
265
|
* Initialize budgetTracker from JSONL file (called once at plugin init).
|
|
215
266
|
*/
|
|
216
|
-
function initBudgetTracker() {
|
|
267
|
+
async function initBudgetTracker() {
|
|
217
268
|
const now = new Date();
|
|
218
269
|
budgetTracker.dayStart = getStartOfDay(now);
|
|
219
270
|
budgetTracker.weekStart = getStartOfWeek(now);
|
|
@@ -225,28 +276,69 @@ function initBudgetTracker() {
|
|
|
225
276
|
return;
|
|
226
277
|
}
|
|
227
278
|
// Load once using the earliest period boundary
|
|
228
|
-
const earliest = Math.min(budgetTracker.dayStart, budgetTracker.weekStart, budgetTracker.monthStart);
|
|
279
|
+
const earliest = Math.min(budget.daily ? budgetTracker.dayStart : Infinity, budget.weekly ? budgetTracker.weekStart : Infinity, budget.monthly ? budgetTracker.monthStart : Infinity);
|
|
229
280
|
if (!existsSync(LOG_FILE)) {
|
|
230
281
|
budgetTracker.initialized = true;
|
|
231
282
|
return;
|
|
232
283
|
}
|
|
284
|
+
let fileHandle = null;
|
|
233
285
|
try {
|
|
234
|
-
const
|
|
235
|
-
const
|
|
286
|
+
const stat = statSync(LOG_FILE);
|
|
287
|
+
const fileSize = stat.size;
|
|
288
|
+
fileHandle = await open(LOG_FILE, "r");
|
|
289
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
290
|
+
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
291
|
+
let filePos = fileSize;
|
|
292
|
+
let leftover = "";
|
|
293
|
+
let shouldStop = false;
|
|
236
294
|
let daily = 0;
|
|
237
295
|
let weekly = 0;
|
|
238
296
|
let monthly = 0;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
297
|
+
while (filePos > 0 && !shouldStop) {
|
|
298
|
+
const readLength = Math.min(CHUNK_SIZE, filePos);
|
|
299
|
+
filePos -= readLength;
|
|
300
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, readLength, filePos);
|
|
301
|
+
const chunkStr = buffer.toString("utf8", 0, bytesRead) + leftover;
|
|
302
|
+
const lines = chunkStr.split("\n");
|
|
303
|
+
// The leftmost line could be cut off, save it for the next chunk read to the left
|
|
304
|
+
leftover = lines[0];
|
|
305
|
+
// Iterate lines in reverse order (from end to start)
|
|
306
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
307
|
+
const line = lines[i].trim();
|
|
308
|
+
if (!line)
|
|
243
309
|
continue;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
310
|
+
try {
|
|
311
|
+
const entry = JSON.parse(line);
|
|
312
|
+
if (entry.type !== "tokens" || !entry.cost)
|
|
313
|
+
continue;
|
|
314
|
+
if (entry._ts < earliest) {
|
|
315
|
+
shouldStop = true;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (entry._ts >= budgetTracker.dayStart)
|
|
319
|
+
daily += entry.cost;
|
|
320
|
+
if (entry._ts >= budgetTracker.weekStart)
|
|
321
|
+
weekly += entry.cost;
|
|
322
|
+
if (entry._ts >= budgetTracker.monthStart)
|
|
323
|
+
monthly += entry.cost;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Skip malformed lines
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Include the very first line at the top
|
|
331
|
+
if (!shouldStop && leftover.trim()) {
|
|
332
|
+
try {
|
|
333
|
+
const entry = JSON.parse(leftover.trim());
|
|
334
|
+
if (entry.type === "tokens" && entry.cost && entry._ts >= earliest) {
|
|
335
|
+
if (entry._ts >= budgetTracker.dayStart)
|
|
336
|
+
daily += entry.cost;
|
|
337
|
+
if (entry._ts >= budgetTracker.weekStart)
|
|
338
|
+
weekly += entry.cost;
|
|
339
|
+
if (entry._ts >= budgetTracker.monthStart)
|
|
340
|
+
monthly += entry.cost;
|
|
341
|
+
}
|
|
250
342
|
}
|
|
251
343
|
catch { }
|
|
252
344
|
}
|
|
@@ -254,7 +346,17 @@ function initBudgetTracker() {
|
|
|
254
346
|
budgetTracker.weeklySpent = weekly;
|
|
255
347
|
budgetTracker.monthlySpent = monthly;
|
|
256
348
|
}
|
|
257
|
-
catch {
|
|
349
|
+
catch (err) {
|
|
350
|
+
// Keep budgetTracker at 0 on error
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
if (fileHandle) {
|
|
354
|
+
try {
|
|
355
|
+
await fileHandle.close();
|
|
356
|
+
}
|
|
357
|
+
catch { }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
258
360
|
budgetTracker.initialized = true;
|
|
259
361
|
}
|
|
260
362
|
/**
|
|
@@ -342,8 +444,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
342
444
|
lastConfigMtime = statSync(CONFIG_FILE).mtimeMs;
|
|
343
445
|
}
|
|
344
446
|
// Initialize in-memory budget tracker (reads JSONL once)
|
|
345
|
-
initBudgetTracker();
|
|
346
|
-
|
|
447
|
+
await initBudgetTracker();
|
|
448
|
+
// 不再写 type:"init" 标记:OpenCode 会在多个子进程(LSP、工具 runner 等)独立加载
|
|
449
|
+
// plugin,每次启动会向 JSONL 写多份重复的 init 行,污染日志且无计费价值。
|
|
450
|
+
// 跨进程去重的根因修复留待后续版本。
|
|
347
451
|
// Show config validation warnings via Toast
|
|
348
452
|
if (configWarnings.length > 0) {
|
|
349
453
|
try {
|
|
@@ -368,6 +472,14 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
368
472
|
const info = props?.info;
|
|
369
473
|
if (!info?.tokens)
|
|
370
474
|
return;
|
|
475
|
+
// 流式中间态保护:仅在消息真正完结时记账,避免对同一条消息重复计费。
|
|
476
|
+
// 完结信号优先用 time.completed;实证发现 provider 的 finish reason
|
|
477
|
+
// 会在 time.completed 之前一帧出现且此时 tokens 已完整,因此把 finish
|
|
478
|
+
// 也作为有效的完结信号,避免极端断流时漏掉最后一帧。
|
|
479
|
+
// 通过 modelID 识别 AI 生成消息(user 消息无此字段),不再硬编码 role。
|
|
480
|
+
if (info.modelID && !info.time?.completed && !info.finish) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
371
483
|
const messageId = info.id;
|
|
372
484
|
const sessionId = info.sessionID;
|
|
373
485
|
if (!messageId || !sessionId)
|
|
@@ -377,10 +489,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
377
489
|
const reasoning = info.tokens.reasoning ?? 0;
|
|
378
490
|
const cacheRead = info.tokens.cache?.read ?? 0;
|
|
379
491
|
const cacheWrite = info.tokens.cache?.write ?? 0;
|
|
380
|
-
const hasTokens = input > 0 || output > 0;
|
|
492
|
+
const hasTokens = input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0;
|
|
381
493
|
if (!hasTokens)
|
|
382
494
|
return;
|
|
383
|
-
const dedupeKey = `${messageId}-${input}-${output}`;
|
|
495
|
+
const dedupeKey = `${messageId}-${input}-${output}-${cacheRead}-${cacheWrite}`;
|
|
384
496
|
if (isDuplicate(dedupeKey))
|
|
385
497
|
return;
|
|
386
498
|
const model = info.model?.modelID ?? info.modelID ?? "unknown";
|
package/dist/lib/shared.d.ts
CHANGED
|
@@ -45,4 +45,4 @@ export interface ConfigValidationResult {
|
|
|
45
45
|
* Invalid fields are silently corrected to defaults with warnings.
|
|
46
46
|
*/
|
|
47
47
|
export declare function validateConfig(raw: unknown): ConfigValidationResult;
|
|
48
|
-
export declare function findModelConfigPricing(models: TrackerConfig["models"], model: string, provider: string): ModelPricing | undefined;
|
|
48
|
+
export declare function findModelConfigPricing(models: TrackerConfig["models"], model: string, provider: string, partial?: boolean): ModelPricing | undefined;
|
package/dist/lib/shared.js
CHANGED
|
@@ -174,6 +174,12 @@ function validatePricingMap(raw, section, warnings, allowProviderModels = false)
|
|
|
174
174
|
warnings.push(`${entryPath} should be a pricing object, ignoring`);
|
|
175
175
|
continue;
|
|
176
176
|
}
|
|
177
|
+
// If the structure looks like flat pricing (has pricing field keys),
|
|
178
|
+
// don't fall through to nested provider pricing — validatePricingObject
|
|
179
|
+
// already issued the relevant warning for malformed flat pricing.
|
|
180
|
+
if (hasFlatPricingStructure(value)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
177
183
|
const providerPricing = validateNestedPricingMap(value, entryPath, warnings);
|
|
178
184
|
if (Object.keys(providerPricing).length > 0) {
|
|
179
185
|
result[key] = providerPricing;
|
|
@@ -246,11 +252,7 @@ function isPlainObject(value) {
|
|
|
246
252
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
247
253
|
}
|
|
248
254
|
function isDirectModelPricing(value) {
|
|
249
|
-
return hasFlatPricingStructure(value)
|
|
250
|
-
&& isFiniteNumber(value.input)
|
|
251
|
-
&& isFiniteNumber(value.output)
|
|
252
|
-
&& (value.cacheRead === undefined || isFiniteNumber(value.cacheRead))
|
|
253
|
-
&& (value.cacheWrite === undefined || isFiniteNumber(value.cacheWrite));
|
|
255
|
+
return hasFlatPricingStructure(value);
|
|
254
256
|
}
|
|
255
257
|
function resolveModelConfigEntry(entry, provider) {
|
|
256
258
|
if (!entry)
|
|
@@ -259,13 +261,17 @@ function resolveModelConfigEntry(entry, provider) {
|
|
|
259
261
|
return entry;
|
|
260
262
|
return entry[provider];
|
|
261
263
|
}
|
|
262
|
-
export function findModelConfigPricing(models, model, provider) {
|
|
264
|
+
export function findModelConfigPricing(models, model, provider, partial = true) {
|
|
263
265
|
const exactMatch = resolveModelConfigEntry(models[model], provider);
|
|
264
266
|
if (exactMatch) {
|
|
265
267
|
return exactMatch;
|
|
266
268
|
}
|
|
269
|
+
if (!partial)
|
|
270
|
+
return undefined;
|
|
267
271
|
const modelLower = model.toLowerCase();
|
|
268
|
-
|
|
272
|
+
// Sort by key length descending so longer (more specific) keys are checked first
|
|
273
|
+
const sorted = Object.entries(models).sort(([a], [b]) => b.length - a.length);
|
|
274
|
+
for (const [key, entry] of sorted) {
|
|
269
275
|
if (modelLower.includes(key.toLowerCase())) {
|
|
270
276
|
const partialMatch = resolveModelConfigEntry(entry, provider);
|
|
271
277
|
if (partialMatch) {
|
package/dist/test/shared.test.js
CHANGED
|
@@ -237,6 +237,16 @@ describe("validateConfig", () => {
|
|
|
237
237
|
assert.equal(result.config.models["not-object"], undefined);
|
|
238
238
|
assert.ok(result.warnings.length >= 4);
|
|
239
239
|
});
|
|
240
|
+
it("should produce one clear warning for malformed flat pricing (not nested fallthrough noise)", () => {
|
|
241
|
+
const result = validateConfig({
|
|
242
|
+
models: {
|
|
243
|
+
"my-model": { input: "free", output: 2 },
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
assert.equal(result.config.models["my-model"], undefined);
|
|
247
|
+
assert.equal(result.warnings.length, 1);
|
|
248
|
+
assert.ok(result.warnings[0].includes("input should be a non-negative number"));
|
|
249
|
+
});
|
|
240
250
|
it("should warn and ignore invalid cacheRead/cacheWrite but keep entry", () => {
|
|
241
251
|
const result = validateConfig({
|
|
242
252
|
models: {
|
|
@@ -376,6 +386,40 @@ describe("findModelConfigPricing", () => {
|
|
|
376
386
|
});
|
|
377
387
|
assert.deepEqual(findModelConfigPricing(result.config.models, "prefix/my-model", "any-provider"), { input: 1, output: 2 });
|
|
378
388
|
});
|
|
389
|
+
it("should prefer longer key over shorter key in partial matches (longest-first)", () => {
|
|
390
|
+
const result = validateConfig({
|
|
391
|
+
models: {
|
|
392
|
+
"gpt-4.1": { input: 3, output: 12 },
|
|
393
|
+
"gpt-4.1-mini": { input: 0.8, output: 3.2 },
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
assert.deepEqual(findModelConfigPricing(result.config.models, "gpt-4.1-mini-2025", "openai"), { input: 0.8, output: 3.2 });
|
|
397
|
+
});
|
|
398
|
+
it("should prefer longer key regardless of insertion order (longest-first)", () => {
|
|
399
|
+
const result = validateConfig({
|
|
400
|
+
models: {
|
|
401
|
+
"gpt-4.1-mini": { input: 0.8, output: 3.2 },
|
|
402
|
+
"gpt-4.1": { input: 3, output: 12 },
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
assert.deepEqual(findModelConfigPricing(result.config.models, "gpt-4.1-mini-2025", "openai"), { input: 0.8, output: 3.2 });
|
|
406
|
+
});
|
|
407
|
+
it("should not match partial keys when partial=false (exact-only mode)", () => {
|
|
408
|
+
const result = validateConfig({
|
|
409
|
+
models: {
|
|
410
|
+
"claude": { input: 0, output: 0 },
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
assert.equal(findModelConfigPricing(result.config.models, "claude-opus-4.6", "anthropic", false), undefined);
|
|
414
|
+
});
|
|
415
|
+
it("should match partial keys when partial=true (backward compatible)", () => {
|
|
416
|
+
const result = validateConfig({
|
|
417
|
+
models: {
|
|
418
|
+
"claude": { input: 0, output: 0 },
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
assert.deepEqual(findModelConfigPricing(result.config.models, "claude-opus-4.6", "anthropic", true), { input: 0, output: 0 });
|
|
422
|
+
});
|
|
379
423
|
});
|
|
380
424
|
// ============================================================================
|
|
381
425
|
// getProviderFamily
|
|
@@ -455,3 +499,38 @@ describe("calculateCost", () => {
|
|
|
455
499
|
assert.ok(Math.abs(costFallback - 4.91) < 0.0001, `expected 4.91, got ${costFallback}`);
|
|
456
500
|
});
|
|
457
501
|
});
|
|
502
|
+
// ============================================================================
|
|
503
|
+
// calculateCost partial match — longest-key-first regression
|
|
504
|
+
// ============================================================================
|
|
505
|
+
describe("calculateCost partial match (longest-key-first)", () => {
|
|
506
|
+
it("should match gpt-4o-mini over gpt-4o for variant model name", () => {
|
|
507
|
+
// gpt-4o-mini-2024-07-18 should match gpt-4o-mini ($0.15/$0.6), not gpt-4o ($2.5/$10)
|
|
508
|
+
const cost = calculateCost("gpt-4o-mini-2024-07-18", "openai", 1_000_000, 1_000_000);
|
|
509
|
+
// Expected: input 0.15 + output 0.6 = 0.75 (no cache in this test)
|
|
510
|
+
assert.ok(Math.abs(cost - 0.75) < 0.001, `expected 0.75, got ${cost}`);
|
|
511
|
+
});
|
|
512
|
+
it("should match o3-mini over o3 for variant model name", () => {
|
|
513
|
+
// o3-mini-high should match o3-mini ($1.1/$4.4), not o3 ($10/$40)
|
|
514
|
+
const cost = calculateCost("o3-mini-high", "openai", 1_000_000, 1_000_000);
|
|
515
|
+
// Expected: input 1.1 + output 4.4 = 5.5
|
|
516
|
+
assert.ok(Math.abs(cost - 5.5) < 0.001, `expected 5.5, got ${cost}`);
|
|
517
|
+
});
|
|
518
|
+
it("should match gemini-2.5-flash-lite over gemini-2.5-flash for variant model name", () => {
|
|
519
|
+
// gemini-2.5-flash-lite-preview should match gemini-2.5-flash-lite ($0.1/$0.4), not gemini-2.5-flash ($0.3/$2.5)
|
|
520
|
+
const cost = calculateCost("gemini-2.5-flash-lite-preview", "google", 1_000_000, 1_000_000);
|
|
521
|
+
// Expected: input 0.1 + output 0.4 = 0.5
|
|
522
|
+
assert.ok(Math.abs(cost - 0.5) < 0.001, `expected 0.5, got ${cost}`);
|
|
523
|
+
});
|
|
524
|
+
it("should match gpt-5.2-pro over gpt-5.2 for variant model name", () => {
|
|
525
|
+
// gpt-5.2-pro-2025 should match gpt-5.2-pro ($21/$168), not gpt-5.2 ($1.75/$14)
|
|
526
|
+
const cost = calculateCost("gpt-5.2-pro-2025", "openai", 1_000_000, 1_000_000);
|
|
527
|
+
// Expected: input 21 + output 168 = 189
|
|
528
|
+
assert.ok(Math.abs(cost - 189) < 0.01, `expected 189, got ${cost}`);
|
|
529
|
+
});
|
|
530
|
+
it("should match o1-mini over o1 for variant model name", () => {
|
|
531
|
+
// o1-mini-high should match o1-mini ($1.1/$4.4), not o1 ($15/$60)
|
|
532
|
+
const cost = calculateCost("o1-mini-high", "openai", 1_000_000, 1_000_000);
|
|
533
|
+
// Expected: input 1.1 + output 4.4 = 5.5
|
|
534
|
+
assert.ok(Math.abs(cost - 5.5) < 0.001, `expected 5.5, got ${cost}`);
|
|
535
|
+
});
|
|
536
|
+
});
|
package/package.json
CHANGED