opencode-token-tracker 1.5.0 → 1.5.2

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) {
@@ -272,140 +267,148 @@ function formatBudgetMessage(status) {
272
267
  return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
273
268
  }
274
269
  export const TokenTrackerPlugin = async ({ directory, client }) => {
275
- // Load config on plugin init (with validation)
276
- config = loadConfig();
277
- // Initialize in-memory budget tracker (reads JSONL once)
278
- initBudgetTracker();
279
- logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
280
- // Show config validation warnings via Toast
281
- if (configWarnings.length > 0) {
282
- try {
283
- await client.tui.showToast({
284
- body: {
285
- title: "Token Tracker: config warning",
286
- message: configWarnings.join("; "),
287
- variant: "warning",
288
- duration: 5000,
289
- },
290
- });
291
- }
292
- catch { }
293
- }
294
- return {
295
- event: async ({ event }) => {
270
+ try {
271
+ // Load config on plugin init (with validation)
272
+ config = loadConfig();
273
+ // Initialize in-memory budget tracker (reads JSONL once)
274
+ initBudgetTracker();
275
+ logJson({ type: "init", directory, configLoaded: existsSync(CONFIG_FILE) });
276
+ // Show config validation warnings via Toast
277
+ if (configWarnings.length > 0) {
296
278
  try {
297
- // Handle message updates (token tracking)
298
- if (event.type === "message.updated") {
299
- const props = event.properties;
300
- const info = props?.info;
301
- if (!info?.tokens)
302
- return;
303
- const messageId = info.id;
304
- const sessionId = info.sessionID;
305
- if (!messageId || !sessionId)
306
- return;
307
- const input = info.tokens.input ?? 0;
308
- const output = info.tokens.output ?? 0;
309
- const reasoning = info.tokens.reasoning ?? 0;
310
- const cacheRead = info.tokens.cache?.read ?? 0;
311
- const cacheWrite = info.tokens.cache?.write ?? 0;
312
- const hasTokens = input > 0 || output > 0;
313
- if (!hasTokens)
314
- return;
315
- const dedupeKey = `${messageId}-${input}-${output}`;
316
- if (isDuplicate(dedupeKey))
317
- return;
318
- const model = info.model?.modelID ?? info.modelID ?? "unknown";
319
- const provider = info.model?.providerID ?? info.providerID ?? "unknown";
320
- const cost = calculateCost(model, provider, input, output, cacheRead, cacheWrite);
321
- // Update session stats
322
- const stats = getOrCreateSessionStats(sessionId);
323
- stats.totalInput += input;
324
- stats.totalOutput += output;
325
- stats.totalReasoning += reasoning;
326
- stats.totalCacheRead += cacheRead;
327
- stats.totalCacheWrite += cacheWrite;
328
- stats.totalCost += cost;
329
- stats.messageCount += 1;
330
- // Log to file
331
- logJson({
332
- type: "tokens",
333
- sessionId,
334
- messageId,
335
- role: info.role,
336
- agent: info.agent,
337
- model,
338
- provider,
339
- input,
340
- output,
341
- reasoning,
342
- cacheRead,
343
- cacheWrite,
344
- cost,
345
- });
346
- // Accumulate cost into in-memory budget tracker
347
- accumulateBudget(cost);
348
- // Show toast for this message
349
- if (config.toast.enabled) {
350
- const totalTokens = input + output;
351
- // Check budget status
352
- const budgetStatus = checkBudgetStatus();
353
- let title = `${formatTokens(totalTokens)} tokens`;
354
- let message = `${formatCost(cost)} | Session: ${formatCost(stats.totalCost)}`;
355
- let variant = "info";
356
- // Add budget warning/alert if applicable
357
- if (budgetStatus) {
358
- if (budgetStatus.exceeded) {
359
- title = `⚠️ Budget exceeded!`;
360
- message = formatBudgetMessage(budgetStatus);
361
- variant = "error";
279
+ await client.tui.showToast({
280
+ body: {
281
+ title: "Token Tracker: config warning",
282
+ message: configWarnings.join("; "),
283
+ variant: "warning",
284
+ duration: 5000,
285
+ },
286
+ });
287
+ }
288
+ catch { }
289
+ }
290
+ return {
291
+ event: async ({ event }) => {
292
+ try {
293
+ // Handle message updates (token tracking)
294
+ if (event.type === "message.updated") {
295
+ const props = event.properties;
296
+ const info = props?.info;
297
+ if (!info?.tokens)
298
+ return;
299
+ const messageId = info.id;
300
+ const sessionId = info.sessionID;
301
+ if (!messageId || !sessionId)
302
+ return;
303
+ const input = info.tokens.input ?? 0;
304
+ const output = info.tokens.output ?? 0;
305
+ const reasoning = info.tokens.reasoning ?? 0;
306
+ const cacheRead = info.tokens.cache?.read ?? 0;
307
+ const cacheWrite = info.tokens.cache?.write ?? 0;
308
+ const hasTokens = input > 0 || output > 0;
309
+ if (!hasTokens)
310
+ return;
311
+ const dedupeKey = `${messageId}-${input}-${output}`;
312
+ if (isDuplicate(dedupeKey))
313
+ return;
314
+ const model = info.model?.modelID ?? info.modelID ?? "unknown";
315
+ const provider = info.model?.providerID ?? info.providerID ?? "unknown";
316
+ const cost = calculateCost(model, provider, input, output, cacheRead, cacheWrite);
317
+ // Update session stats
318
+ const stats = getOrCreateSessionStats(sessionId);
319
+ stats.totalInput += input;
320
+ stats.totalOutput += output;
321
+ stats.totalReasoning += reasoning;
322
+ stats.totalCacheRead += cacheRead;
323
+ stats.totalCacheWrite += cacheWrite;
324
+ stats.totalCost += cost;
325
+ stats.messageCount += 1;
326
+ // Log to file
327
+ logJson({
328
+ type: "tokens",
329
+ sessionId,
330
+ messageId,
331
+ role: info.role,
332
+ agent: info.agent,
333
+ model,
334
+ provider,
335
+ input,
336
+ output,
337
+ reasoning,
338
+ cacheRead,
339
+ cacheWrite,
340
+ cost,
341
+ });
342
+ // Accumulate cost into in-memory budget tracker
343
+ accumulateBudget(cost);
344
+ // Show toast for this message
345
+ if (config.toast.enabled) {
346
+ const totalTokens = input + output;
347
+ // Check budget status
348
+ const budgetStatus = checkBudgetStatus();
349
+ let title = `${formatTokens(totalTokens)} tokens`;
350
+ let message = `${formatCost(cost)} | Session: ${formatCost(stats.totalCost)}`;
351
+ let variant = "info";
352
+ // Add budget warning/alert if applicable
353
+ if (budgetStatus) {
354
+ if (budgetStatus.exceeded) {
355
+ title = `⚠️ Budget exceeded!`;
356
+ message = formatBudgetMessage(budgetStatus);
357
+ variant = "error";
358
+ }
359
+ else if (budgetStatus.warning) {
360
+ message = `${formatCost(cost)} | ${formatBudgetMessage(budgetStatus)}`;
361
+ variant = "warning";
362
+ }
362
363
  }
363
- else if (budgetStatus.warning) {
364
- message = `${formatCost(cost)} | ${formatBudgetMessage(budgetStatus)}`;
365
- variant = "warning";
364
+ try {
365
+ await client.tui.showToast({
366
+ body: {
367
+ title,
368
+ message,
369
+ variant,
370
+ duration: budgetStatus?.exceeded ? 5000 : config.toast.duration,
371
+ },
372
+ });
366
373
  }
374
+ catch { }
367
375
  }
376
+ }
377
+ // Handle session idle (show summary)
378
+ if (event.type === "session.idle") {
379
+ if (!config.toast.enabled || !config.toast.showOnIdle)
380
+ return;
381
+ const props = event.properties;
382
+ const sessionId = props?.sessionID;
383
+ if (!sessionId)
384
+ return;
385
+ const stats = sessionStats.get(sessionId);
386
+ if (!stats || stats.messageCount === 0)
387
+ return;
388
+ const duration = Math.round((Date.now() - stats.startTime) / 1000 / 60);
389
+ const totalTokens = stats.totalInput + stats.totalOutput;
368
390
  try {
369
391
  await client.tui.showToast({
370
392
  body: {
371
- title,
372
- message,
373
- variant,
374
- duration: budgetStatus?.exceeded ? 5000 : config.toast.duration,
393
+ title: `Session: ${formatTokens(totalTokens)} tokens`,
394
+ message: `${formatCost(stats.totalCost)} | ${stats.messageCount} msgs | ${duration}min`,
395
+ variant: "info",
396
+ duration: 5000,
375
397
  },
376
398
  });
377
399
  }
378
400
  catch { }
379
401
  }
380
402
  }
381
- // Handle session idle (show summary)
382
- if (event.type === "session.idle") {
383
- if (!config.toast.enabled || !config.toast.showOnIdle)
384
- return;
385
- const props = event.properties;
386
- const sessionId = props?.sessionID;
387
- if (!sessionId)
388
- return;
389
- const stats = sessionStats.get(sessionId);
390
- if (!stats || stats.messageCount === 0)
391
- return;
392
- const duration = Math.round((Date.now() - stats.startTime) / 1000 / 60);
393
- const totalTokens = stats.totalInput + stats.totalOutput;
394
- try {
395
- await client.tui.showToast({
396
- body: {
397
- title: `Session: ${formatTokens(totalTokens)} tokens`,
398
- message: `${formatCost(stats.totalCost)} | ${stats.messageCount} msgs | ${duration}min`,
399
- variant: "info",
400
- duration: 5000,
401
- },
402
- });
403
- }
404
- catch { }
405
- }
406
- }
407
- catch { }
408
- },
409
- };
403
+ catch { }
404
+ },
405
+ };
406
+ }
407
+ catch (err) {
408
+ console.error("[Token Tracker] Initialization failed:", err);
409
+ return {
410
+ event: async () => { },
411
+ };
412
+ }
410
413
  };
411
414
  export default TokenTrackerPlugin;
@@ -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.2",
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",