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 +21 -3
- package/README.zh-CN.md +21 -3
- package/dist/bin/opencode-tokens.js +2 -2
- package/dist/index.js +136 -133
- package/dist/lib/shared.d.ts +5 -1
- package/dist/lib/shared.js +104 -26
- package/dist/test/shared.test.js +94 -1
- package/package.json +1 -1
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. **
|
|
304
|
-
3. **
|
|
305
|
-
4. **
|
|
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.
|
|
289
|
-
3.
|
|
290
|
-
4.
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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:
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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;
|
package/dist/lib/shared.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/shared.js
CHANGED
|
@@ -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
|
|
168
|
-
|
|
169
|
-
|
|
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 (!
|
|
173
|
-
warnings.push(`${
|
|
173
|
+
if (!allowProviderModels) {
|
|
174
|
+
warnings.push(`${entryPath} should be a pricing object, ignoring`);
|
|
174
175
|
continue;
|
|
175
176
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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)
|
package/dist/test/shared.test.js
CHANGED
|
@@ -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