opencode-token-tracker 1.5.6 → 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 +9 -4
- 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 +7 -6
|
@@ -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
|
@@ -58,8 +58,8 @@ function getModelPricing(model, provider) {
|
|
|
58
58
|
if (config.providers[provider]) {
|
|
59
59
|
return config.providers[provider];
|
|
60
60
|
}
|
|
61
|
-
// 2. Check user-defined model pricing
|
|
62
|
-
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);
|
|
63
63
|
if (configuredPricing) {
|
|
64
64
|
return configuredPricing;
|
|
65
65
|
}
|
|
@@ -69,12 +69,17 @@ function getModelPricing(model, provider) {
|
|
|
69
69
|
}
|
|
70
70
|
// 4. Try partial match in built-in pricing
|
|
71
71
|
const modelLower = model.toLowerCase();
|
|
72
|
-
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)) {
|
|
73
73
|
if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
|
|
74
74
|
return pricing;
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
-
// 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
|
|
78
83
|
return BUILTIN_PRICING["_default"];
|
|
79
84
|
}
|
|
80
85
|
export function getProviderFamily(model, provider) {
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-token-tracker",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.7",
|
|
4
4
|
"description": "Real-time token usage and cost tracking plugin for OpenCode with Toast notifications and CLI stats",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"opencode-tokens": "dist/bin/opencode-tokens.js"
|
|
10
10
|
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"test": "tsc && node --test dist/test/shared.test.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
11
16
|
"keywords": [
|
|
12
17
|
"opencode",
|
|
13
18
|
"plugin",
|
|
@@ -46,9 +51,5 @@
|
|
|
46
51
|
],
|
|
47
52
|
"engines": {
|
|
48
53
|
"node": ">=18.0.0"
|
|
49
|
-
},
|
|
50
|
-
"scripts": {
|
|
51
|
-
"build": "tsc",
|
|
52
|
-
"test": "tsc && node --test dist/test/shared.test.js"
|
|
53
54
|
}
|
|
54
|
-
}
|
|
55
|
+
}
|