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.
@@ -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 = "built-in";
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 (!BUILTIN_PRICING[model]) {
336
- // Check partial match
337
- const hasMatch = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && model.toLowerCase().includes(k.toLowerCase()));
338
- status = hasMatch ? "built-in" : "default";
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. Fallback to default
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) {
@@ -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;
@@ -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
- for (const [key, entry] of Object.entries(models)) {
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) {
@@ -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.6",
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
+ }