opencode-token-tracker 1.5.0 → 1.5.1

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/README.md CHANGED
@@ -300,9 +300,10 @@ All prices are in **USD per 1 million tokens**:
300
300
  Pricing is resolved in this order (first match wins):
301
301
 
302
302
  1. **Provider-level** - Override all models for a provider
303
- 2. **User model config** - Custom model pricing in config file
304
- 3. **Built-in pricing** - Default pricing table
305
- 4. **Fallback** - $1/M input, $4/M output
303
+ 2. **Provider-specific model config** - Custom pricing for the same model under different providers
304
+ 3. **User model config** - Generic custom model pricing in config file
305
+ 4. **Built-in pricing** - Default pricing table
306
+ 5. **Fallback** - $1/M input, $4/M output
306
307
 
307
308
  #### Example: Free providers
308
309
 
@@ -330,6 +331,23 @@ Override or add pricing for specific models (prices in USD per 1M tokens):
330
331
  }
331
332
  ```
332
333
 
334
+ #### Example: Same model, different provider pricing
335
+
336
+ If the same model has different prices under different providers, nest provider names under the model key:
337
+
338
+ ```json
339
+ {
340
+ "models": {
341
+ "deepseek/deepseek-v4-flash": {
342
+ "openrouter": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028 },
343
+ "siliconflow": { "input": 0.2, "output": 0.4 }
344
+ }
345
+ }
346
+ }
347
+ ```
348
+
349
+ You can still mix this with the original flat model pricing format.
350
+
333
351
  ### Toast Settings
334
352
 
335
353
  | Option | Type | Default | Description |
package/README.zh-CN.md CHANGED
@@ -285,9 +285,10 @@ token 记录保存在:
285
285
  定价解析顺序(命中即止):
286
286
 
287
287
  1. **Provider 覆盖** — 为某个 provider 的所有模型统一设置
288
- 2. **用户 model 配置** — 为特定模型自定义定价
289
- 3. **内置定价**默认定价表
290
- 4. **默认回退**$1/M input,$4/M output
288
+ 2. **按 provider 的 model 配置** — 同一模型在不同 provider 下使用不同定价
289
+ 3. **用户 model 配置** 为特定模型自定义通用定价
290
+ 4. **内置定价**默认定价表
291
+ 5. **默认回退** — $1/M input,$4/M output
291
292
 
292
293
  #### 示例:免费 provider
293
294
 
@@ -315,6 +316,23 @@ token 记录保存在:
315
316
  }
316
317
  ```
317
318
 
319
+ #### 示例:同一模型在不同 provider 下使用不同价格
320
+
321
+ 如果同一个模型在不同 provider 下价格不同,可以在模型名下继续按 provider 配置:
322
+
323
+ ```json
324
+ {
325
+ "models": {
326
+ "deepseek/deepseek-v4-flash": {
327
+ "openrouter": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028 },
328
+ "siliconflow": { "input": 0.2, "output": 0.4 }
329
+ }
330
+ }
331
+ }
332
+ ```
333
+
334
+ 这种写法可以和原来的扁平 `models` 配置同时使用。
335
+
318
336
  ### 预算设置
319
337
 
320
338
  | 选项 | 类型 | 默认值 | 说明 |
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { BUILTIN_PRICING, DEFAULT_CONFIG, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "../lib/shared.js";
2
+ import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "../lib/shared.js";
3
3
  import { readFileSync, existsSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
5
  import { homedir } from "os";
@@ -292,7 +292,7 @@ function cmdModels() {
292
292
  if (config.providers?.[provider]) {
293
293
  status = "provider cfg";
294
294
  }
295
- else if (config.models?.[model]) {
295
+ else if (findModelConfigPricing(config.models, model, provider)) {
296
296
  status = "model cfg";
297
297
  }
298
298
  else if (!BUILTIN_PRICING[model]) {
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BUILTIN_PRICING, DEFAULT_CONFIG, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
1
+ import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
@@ -36,27 +36,22 @@ function getModelPricing(model, provider) {
36
36
  return config.providers[provider];
37
37
  }
38
38
  // 2. Check user-defined model pricing
39
- if (config.models[model]) {
40
- return config.models[model];
39
+ const configuredPricing = findModelConfigPricing(config.models, model, provider);
40
+ if (configuredPricing) {
41
+ return configuredPricing;
41
42
  }
42
43
  // 3. Check built-in exact match
43
44
  if (BUILTIN_PRICING[model]) {
44
45
  return BUILTIN_PRICING[model];
45
46
  }
46
- // 4. Try partial match in user config
47
+ // 4. Try partial match in built-in pricing
47
48
  const modelLower = model.toLowerCase();
48
- for (const [key, pricing] of Object.entries(config.models)) {
49
- if (modelLower.includes(key.toLowerCase())) {
50
- return pricing;
51
- }
52
- }
53
- // 5. Try partial match in built-in pricing
54
49
  for (const [key, pricing] of Object.entries(BUILTIN_PRICING)) {
55
50
  if (key !== "_default" && modelLower.includes(key.toLowerCase())) {
56
51
  return pricing;
57
52
  }
58
53
  }
59
- // 6. Fallback to default
54
+ // 5. Fallback to default
60
55
  return BUILTIN_PRICING["_default"];
61
56
  }
62
57
  function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
@@ -4,6 +4,9 @@ export interface ModelPricing {
4
4
  cacheRead?: number;
5
5
  cacheWrite?: number;
6
6
  }
7
+ export interface ProviderModelPricingMap {
8
+ [provider: string]: ModelPricing;
9
+ }
7
10
  export declare const BUILTIN_PRICING: Record<string, ModelPricing>;
8
11
  export declare function formatCost(cost: number): string;
9
12
  /**
@@ -28,7 +31,7 @@ export interface BudgetConfig {
28
31
  }
29
32
  export interface TrackerConfig {
30
33
  providers: Record<string, ModelPricing>;
31
- models: Record<string, ModelPricing>;
34
+ models: Record<string, ModelPricing | ProviderModelPricingMap>;
32
35
  toast: ToastConfig;
33
36
  budget: BudgetConfig;
34
37
  }
@@ -42,3 +45,4 @@ export interface ConfigValidationResult {
42
45
  * Invalid fields are silently corrected to defaults with warnings.
43
46
  */
44
47
  export declare function validateConfig(raw: unknown): ConfigValidationResult;
48
+ export declare function findModelConfigPricing(models: TrackerConfig["models"], model: string, provider: string): ModelPricing | undefined;
@@ -139,7 +139,7 @@ export function validateConfig(raw) {
139
139
  }
140
140
  const obj = raw;
141
141
  const providers = validatePricingMap(obj["providers"], "providers", warnings);
142
- const models = validatePricingMap(obj["models"], "models", warnings);
142
+ const models = validatePricingMap(obj["models"], "models", warnings, true);
143
143
  const toast = validateToast(obj["toast"], warnings);
144
144
  const budget = validateBudget(obj["budget"], warnings);
145
145
  return {
@@ -150,7 +150,7 @@ export function validateConfig(raw) {
150
150
  function isFiniteNumber(v) {
151
151
  return typeof v === "number" && Number.isFinite(v);
152
152
  }
153
- function validatePricingMap(raw, section, warnings) {
153
+ function validatePricingMap(raw, section, warnings, allowProviderModels = false) {
154
154
  if (raw === undefined || raw === null)
155
155
  return {};
156
156
  if (typeof raw !== "object" || Array.isArray(raw)) {
@@ -164,39 +164,117 @@ function validatePricingMap(raw, section, warnings) {
164
164
  warnings.push(`${section}.${key} should be a pricing object, ignoring`);
165
165
  continue;
166
166
  }
167
- const p = value;
168
- if (!isFiniteNumber(p["input"]) || p["input"] < 0) {
169
- warnings.push(`${section}.${key}.input should be a non-negative number, ignoring entry`);
167
+ const entryPath = `${section}.${key}`;
168
+ const parsedPricing = validatePricingObject(value, entryPath, warnings);
169
+ if (parsedPricing) {
170
+ result[key] = parsedPricing;
170
171
  continue;
171
172
  }
172
- if (!isFiniteNumber(p["output"]) || p["output"] < 0) {
173
- warnings.push(`${section}.${key}.output should be a non-negative number, ignoring entry`);
173
+ if (!allowProviderModels) {
174
+ warnings.push(`${entryPath} should be a pricing object, ignoring`);
174
175
  continue;
175
176
  }
176
- const pricing = {
177
- input: p["input"],
178
- output: p["output"],
179
- };
180
- if (p["cacheRead"] !== undefined) {
181
- if (isFiniteNumber(p["cacheRead"]) && p["cacheRead"] >= 0) {
182
- pricing.cacheRead = p["cacheRead"];
183
- }
184
- else {
185
- warnings.push(`${section}.${key}.cacheRead should be a non-negative number, ignoring field`);
186
- }
177
+ const providerPricing = validateNestedPricingMap(value, entryPath, warnings);
178
+ if (Object.keys(providerPricing).length > 0) {
179
+ result[key] = providerPricing;
187
180
  }
188
- if (p["cacheWrite"] !== undefined) {
189
- if (isFiniteNumber(p["cacheWrite"]) && p["cacheWrite"] >= 0) {
190
- pricing.cacheWrite = p["cacheWrite"];
191
- }
192
- else {
193
- warnings.push(`${section}.${key}.cacheWrite should be a non-negative number, ignoring field`);
194
- }
181
+ else {
182
+ warnings.push(`${entryPath} should define at least one valid provider pricing, ignoring entry`);
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+ function validateNestedPricingMap(raw, path, warnings) {
188
+ const result = {};
189
+ for (const [provider, value] of Object.entries(raw)) {
190
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
191
+ warnings.push(`${path}.${provider} should be a pricing object, ignoring`);
192
+ continue;
193
+ }
194
+ const pricing = validatePricingObject(value, `${path}.${provider}`, warnings);
195
+ if (pricing) {
196
+ result[provider] = pricing;
195
197
  }
196
- result[key] = pricing;
197
198
  }
198
199
  return result;
199
200
  }
201
+ function validatePricingObject(raw, path, warnings) {
202
+ if (!hasFlatPricingStructure(raw)) {
203
+ return undefined;
204
+ }
205
+ if (!isFiniteNumber(raw["input"]) || raw["input"] < 0) {
206
+ warnings.push(`${path}.input should be a non-negative number, ignoring entry`);
207
+ return undefined;
208
+ }
209
+ if (!isFiniteNumber(raw["output"]) || raw["output"] < 0) {
210
+ warnings.push(`${path}.output should be a non-negative number, ignoring entry`);
211
+ return undefined;
212
+ }
213
+ const pricing = {
214
+ input: raw["input"],
215
+ output: raw["output"],
216
+ };
217
+ if (raw["cacheRead"] !== undefined) {
218
+ if (isFiniteNumber(raw["cacheRead"]) && raw["cacheRead"] >= 0) {
219
+ pricing.cacheRead = raw["cacheRead"];
220
+ }
221
+ else {
222
+ warnings.push(`${path}.cacheRead should be a non-negative number, ignoring field`);
223
+ }
224
+ }
225
+ if (raw["cacheWrite"] !== undefined) {
226
+ if (isFiniteNumber(raw["cacheWrite"]) && raw["cacheWrite"] >= 0) {
227
+ pricing.cacheWrite = raw["cacheWrite"];
228
+ }
229
+ else {
230
+ warnings.push(`${path}.cacheWrite should be a non-negative number, ignoring field`);
231
+ }
232
+ }
233
+ return pricing;
234
+ }
235
+ function hasFlatPricingStructure(raw) {
236
+ const hasPricingField = "input" in raw || "output" in raw || "cacheRead" in raw || "cacheWrite" in raw;
237
+ if (!hasPricingField) {
238
+ return false;
239
+ }
240
+ return !isPlainObject(raw["input"])
241
+ && !isPlainObject(raw["output"])
242
+ && !isPlainObject(raw["cacheRead"])
243
+ && !isPlainObject(raw["cacheWrite"]);
244
+ }
245
+ function isPlainObject(value) {
246
+ return value !== null && typeof value === "object" && !Array.isArray(value);
247
+ }
248
+ 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));
254
+ }
255
+ function resolveModelConfigEntry(entry, provider) {
256
+ if (!entry)
257
+ return undefined;
258
+ if (isDirectModelPricing(entry))
259
+ return entry;
260
+ return entry[provider];
261
+ }
262
+ export function findModelConfigPricing(models, model, provider) {
263
+ const exactMatch = resolveModelConfigEntry(models[model], provider);
264
+ if (exactMatch) {
265
+ return exactMatch;
266
+ }
267
+ const modelLower = model.toLowerCase();
268
+ for (const [key, entry] of Object.entries(models)) {
269
+ if (modelLower.includes(key.toLowerCase())) {
270
+ const partialMatch = resolveModelConfigEntry(entry, provider);
271
+ if (partialMatch) {
272
+ return partialMatch;
273
+ }
274
+ }
275
+ }
276
+ return undefined;
277
+ }
200
278
  function validateToast(raw, warnings) {
201
279
  const defaults = DEFAULT_CONFIG.toast;
202
280
  if (raw === undefined || raw === null)
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { BUILTIN_PRICING, DEFAULT_CONFIG, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig, } from "../lib/shared.js";
3
+ import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig, } from "../lib/shared.js";
4
4
  // ============================================================================
5
5
  // formatCost
6
6
  // ============================================================================
@@ -197,6 +197,21 @@ describe("validateConfig", () => {
197
197
  assert.equal(result.config.budget.daily, 10);
198
198
  assert.equal(result.config.budget.warnAt, 0.9);
199
199
  });
200
+ it("should accept provider-specific model pricing", () => {
201
+ const result = validateConfig({
202
+ models: {
203
+ "deepseek/deepseek-v4-flash": {
204
+ "openrouter": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
205
+ "siliconflow": { input: 0.2, output: 0.4 },
206
+ },
207
+ },
208
+ });
209
+ assert.equal(result.warnings.length, 0);
210
+ assert.deepEqual(result.config.models["deepseek/deepseek-v4-flash"], {
211
+ "openrouter": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
212
+ "siliconflow": { input: 0.2, output: 0.4 },
213
+ });
214
+ });
200
215
  it("should warn and ignore non-object providers/models", () => {
201
216
  const result = validateConfig({ providers: "invalid", models: 123 });
202
217
  assert.deepEqual(result.config.providers, {});
@@ -236,6 +251,35 @@ describe("validateConfig", () => {
236
251
  assert.ok(result.warnings.some(w => w.includes("cacheRead")));
237
252
  assert.ok(result.warnings.some(w => w.includes("cacheWrite")));
238
253
  });
254
+ it("should warn and skip invalid provider-specific model pricing entries", () => {
255
+ const result = validateConfig({
256
+ models: {
257
+ "deepseek/deepseek-v4-flash": {
258
+ "openrouter": { input: 0.14, output: 0.28 },
259
+ "bad-provider": { input: "free", output: 0.28 },
260
+ },
261
+ },
262
+ });
263
+ assert.deepEqual(result.config.models["deepseek/deepseek-v4-flash"], {
264
+ "openrouter": { input: 0.14, output: 0.28 },
265
+ });
266
+ assert.ok(result.warnings.some(w => w.includes("bad-provider.input")));
267
+ });
268
+ it("should allow provider-specific model pricing even when provider name matches pricing fields", () => {
269
+ const result = validateConfig({
270
+ models: {
271
+ "field-named-provider-model": {
272
+ "input": { input: 0.14, output: 0.28 },
273
+ "output": { input: 0.2, output: 0.4 },
274
+ },
275
+ },
276
+ });
277
+ assert.equal(result.warnings.length, 0);
278
+ assert.deepEqual(result.config.models["field-named-provider-model"], {
279
+ "input": { input: 0.14, output: 0.28 },
280
+ "output": { input: 0.2, output: 0.4 },
281
+ });
282
+ });
239
283
  it("should warn and use default toast for invalid toast fields", () => {
240
284
  const result = validateConfig({
241
285
  toast: { enabled: "yes", duration: -100, showOnIdle: 1 },
@@ -283,3 +327,52 @@ describe("validateConfig", () => {
283
327
  assert.ok(result.warnings.length >= 3);
284
328
  });
285
329
  });
330
+ describe("findModelConfigPricing", () => {
331
+ it("should prefer provider-specific model pricing when available", () => {
332
+ const result = validateConfig({
333
+ models: {
334
+ "deepseek/deepseek-v4-flash": {
335
+ "openrouter": { input: 0.14, output: 0.28 },
336
+ },
337
+ },
338
+ });
339
+ assert.deepEqual(findModelConfigPricing(result.config.models, "deepseek/deepseek-v4-flash", "openrouter"), { input: 0.14, output: 0.28 });
340
+ assert.equal(findModelConfigPricing(result.config.models, "deepseek/deepseek-v4-flash", "siliconflow"), undefined);
341
+ });
342
+ it("should support partial model matches for provider-specific pricing", () => {
343
+ const result = validateConfig({
344
+ models: {
345
+ "deepseek-v4-flash": {
346
+ "openrouter": { input: 0.14, output: 0.28 },
347
+ },
348
+ },
349
+ });
350
+ assert.deepEqual(findModelConfigPricing(result.config.models, "deepseek/deepseek-v4-flash", "openrouter"), { input: 0.14, output: 0.28 });
351
+ });
352
+ it("should resolve provider-specific model pricing for providers named like pricing fields", () => {
353
+ const result = validateConfig({
354
+ models: {
355
+ "field-named-provider-model": {
356
+ "input": { input: 0.14, output: 0.28 },
357
+ },
358
+ },
359
+ });
360
+ assert.deepEqual(findModelConfigPricing(result.config.models, "field-named-provider-model", "input"), { input: 0.14, output: 0.28 });
361
+ });
362
+ it("should keep supporting direct model pricing", () => {
363
+ const result = validateConfig({
364
+ models: {
365
+ "my-model": { input: 1, output: 2, cacheRead: 0.1 },
366
+ },
367
+ });
368
+ assert.deepEqual(findModelConfigPricing(result.config.models, "my-model", "any-provider"), { input: 1, output: 2, cacheRead: 0.1 });
369
+ });
370
+ it("should support partial model matches for direct model pricing", () => {
371
+ const result = validateConfig({
372
+ models: {
373
+ "my-model": { input: 1, output: 2 },
374
+ },
375
+ });
376
+ assert.deepEqual(findModelConfigPricing(result.config.models, "prefix/my-model", "any-provider"), { input: 1, output: 2 });
377
+ });
378
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-token-tracker",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
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",