opencode-token-tracker 1.5.4 → 1.5.6
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 +0 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +183 -31
- package/dist/test/shared.test.js +79 -0
- package/package.json +6 -7
|
File without changes
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
type ProviderFamily = "anthropic" | "openai" | "deepseek" | "google" | "other";
|
|
3
|
+
export declare function getProviderFamily(model: string, provider: string): ProviderFamily;
|
|
4
|
+
export declare function calculateCost(model: string, provider: string, input: number, output: number, cacheRead?: number, cacheWrite?: number): number;
|
|
2
5
|
export declare const TokenTrackerPlugin: Plugin;
|
|
3
6
|
export default TokenTrackerPlugin;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig } from "./lib/shared.js";
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
3
|
+
import { open } from "fs/promises";
|
|
3
4
|
import { join } from "path";
|
|
4
5
|
import { homedir } from "os";
|
|
5
6
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
@@ -76,14 +77,59 @@ function getModelPricing(model, provider) {
|
|
|
76
77
|
// 5. Fallback to default
|
|
77
78
|
return BUILTIN_PRICING["_default"];
|
|
78
79
|
}
|
|
79
|
-
function
|
|
80
|
+
export function getProviderFamily(model, provider) {
|
|
81
|
+
const p = provider.toLowerCase();
|
|
82
|
+
const m = model.toLowerCase();
|
|
83
|
+
if (p.includes("anthropic") || m.startsWith("claude-")) {
|
|
84
|
+
return "anthropic";
|
|
85
|
+
}
|
|
86
|
+
if (p.includes("openai") ||
|
|
87
|
+
m.startsWith("gpt-") ||
|
|
88
|
+
m.startsWith("o1-") ||
|
|
89
|
+
m.startsWith("o3-") ||
|
|
90
|
+
m.startsWith("o4-") ||
|
|
91
|
+
m === "o3" ||
|
|
92
|
+
m === "o1") {
|
|
93
|
+
return "openai";
|
|
94
|
+
}
|
|
95
|
+
if (p.includes("deepseek") || m.includes("deepseek")) {
|
|
96
|
+
return "deepseek";
|
|
97
|
+
}
|
|
98
|
+
if (p.includes("google") || p.includes("vertex") || m.startsWith("gemini-")) {
|
|
99
|
+
return "google";
|
|
100
|
+
}
|
|
101
|
+
return "other";
|
|
102
|
+
}
|
|
103
|
+
export function calculateCost(model, provider, input, output, cacheRead = 0, cacheWrite = 0) {
|
|
80
104
|
const pricing = getModelPricing(model, provider);
|
|
105
|
+
const family = getProviderFamily(model, provider);
|
|
106
|
+
let defaultCacheReadRate = 0.5; // Default 50% discount (OpenAI style)
|
|
107
|
+
let defaultCacheWriteRate = 0; // Default free cache writing
|
|
108
|
+
if (family === "anthropic") {
|
|
109
|
+
defaultCacheReadRate = 0.1;
|
|
110
|
+
defaultCacheWriteRate = 1.25;
|
|
111
|
+
}
|
|
112
|
+
else if (family === "deepseek" || family === "google") {
|
|
113
|
+
defaultCacheReadRate = 0.1;
|
|
114
|
+
defaultCacheWriteRate = 0;
|
|
115
|
+
}
|
|
116
|
+
else if (family === "openai") {
|
|
117
|
+
defaultCacheReadRate = 0.5;
|
|
118
|
+
defaultCacheWriteRate = 0;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// "other" / general default
|
|
122
|
+
defaultCacheReadRate = 0.5;
|
|
123
|
+
defaultCacheWriteRate = 0;
|
|
124
|
+
}
|
|
125
|
+
const finalCacheReadPrice = pricing.cacheRead ?? (pricing.input * defaultCacheReadRate);
|
|
126
|
+
const finalCacheWritePrice = pricing.cacheWrite ?? (pricing.input * defaultCacheWriteRate);
|
|
81
127
|
// Billable input = total input - cache read (cached tokens are charged at cache rate)
|
|
82
128
|
const billableInput = Math.max(0, input - cacheRead);
|
|
83
129
|
const inputCost = (billableInput / 1_000_000) * pricing.input;
|
|
84
130
|
const outputCost = (output / 1_000_000) * pricing.output;
|
|
85
|
-
const cacheReadCost = (cacheRead / 1_000_000) *
|
|
86
|
-
const cacheWriteCost = (cacheWrite / 1_000_000) *
|
|
131
|
+
const cacheReadCost = (cacheRead / 1_000_000) * finalCacheReadPrice;
|
|
132
|
+
const cacheWriteCost = (cacheWrite / 1_000_000) * finalCacheWritePrice;
|
|
87
133
|
return inputCost + outputCost + cacheReadCost + cacheWriteCost;
|
|
88
134
|
}
|
|
89
135
|
const sessionStats = new Map();
|
|
@@ -146,29 +192,74 @@ const budgetTracker = {
|
|
|
146
192
|
function loadCostsSince(since) {
|
|
147
193
|
if (!existsSync(LOG_FILE))
|
|
148
194
|
return 0;
|
|
195
|
+
let total = 0;
|
|
196
|
+
let fd = null;
|
|
149
197
|
try {
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
198
|
+
fd = openSync(LOG_FILE, "r");
|
|
199
|
+
const stat = statSync(LOG_FILE);
|
|
200
|
+
const fileSize = stat.size;
|
|
201
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
202
|
+
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
203
|
+
let filePos = fileSize;
|
|
204
|
+
let leftover = "";
|
|
205
|
+
let shouldStop = false;
|
|
206
|
+
while (filePos > 0 && !shouldStop) {
|
|
207
|
+
const readLength = Math.min(CHUNK_SIZE, filePos);
|
|
208
|
+
filePos -= readLength;
|
|
209
|
+
readSync(fd, buffer, 0, readLength, filePos);
|
|
210
|
+
const chunkStr = buffer.toString("utf8", 0, readLength) + leftover;
|
|
211
|
+
const lines = chunkStr.split("\n");
|
|
212
|
+
// The leftmost line could be cut off, save it for the next chunk read to the left
|
|
213
|
+
leftover = lines[0];
|
|
214
|
+
// Iterate lines in reverse order (from end to start)
|
|
215
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
216
|
+
const line = lines[i].trim();
|
|
217
|
+
if (!line)
|
|
218
|
+
continue;
|
|
219
|
+
try {
|
|
220
|
+
const entry = JSON.parse(line);
|
|
221
|
+
if (entry.type !== "tokens" || !entry.cost)
|
|
222
|
+
continue;
|
|
223
|
+
if (entry._ts < since) {
|
|
224
|
+
shouldStop = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
total += entry.cost;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Skip malformed lines
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Include the very first line at the top
|
|
235
|
+
if (!shouldStop && leftover.trim()) {
|
|
154
236
|
try {
|
|
155
|
-
const entry = JSON.parse(
|
|
156
|
-
if (entry.type === "tokens" && entry._ts >= since
|
|
237
|
+
const entry = JSON.parse(leftover.trim());
|
|
238
|
+
if (entry.type === "tokens" && entry.cost && entry._ts >= since) {
|
|
157
239
|
total += entry.cost;
|
|
158
240
|
}
|
|
159
241
|
}
|
|
160
242
|
catch { }
|
|
161
243
|
}
|
|
162
|
-
return total;
|
|
163
244
|
}
|
|
164
245
|
catch {
|
|
165
|
-
|
|
246
|
+
// 异常路径下放弃部分累加结果,与 1.5.5 之前的语义保持一致,避免下游基于偏小值做预算判断
|
|
247
|
+
total = 0;
|
|
166
248
|
}
|
|
249
|
+
finally {
|
|
250
|
+
if (fd !== null) {
|
|
251
|
+
try {
|
|
252
|
+
closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
catch { }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return total;
|
|
167
258
|
}
|
|
168
259
|
/**
|
|
169
260
|
* Initialize budgetTracker from JSONL file (called once at plugin init).
|
|
170
261
|
*/
|
|
171
|
-
function initBudgetTracker() {
|
|
262
|
+
async function initBudgetTracker() {
|
|
172
263
|
const now = new Date();
|
|
173
264
|
budgetTracker.dayStart = getStartOfDay(now);
|
|
174
265
|
budgetTracker.weekStart = getStartOfWeek(now);
|
|
@@ -180,28 +271,69 @@ function initBudgetTracker() {
|
|
|
180
271
|
return;
|
|
181
272
|
}
|
|
182
273
|
// Load once using the earliest period boundary
|
|
183
|
-
const earliest = Math.min(budgetTracker.dayStart, budgetTracker.weekStart, budgetTracker.monthStart);
|
|
274
|
+
const earliest = Math.min(budget.daily ? budgetTracker.dayStart : Infinity, budget.weekly ? budgetTracker.weekStart : Infinity, budget.monthly ? budgetTracker.monthStart : Infinity);
|
|
184
275
|
if (!existsSync(LOG_FILE)) {
|
|
185
276
|
budgetTracker.initialized = true;
|
|
186
277
|
return;
|
|
187
278
|
}
|
|
279
|
+
let fileHandle = null;
|
|
188
280
|
try {
|
|
189
|
-
const
|
|
190
|
-
const
|
|
281
|
+
const stat = statSync(LOG_FILE);
|
|
282
|
+
const fileSize = stat.size;
|
|
283
|
+
fileHandle = await open(LOG_FILE, "r");
|
|
284
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
285
|
+
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
286
|
+
let filePos = fileSize;
|
|
287
|
+
let leftover = "";
|
|
288
|
+
let shouldStop = false;
|
|
191
289
|
let daily = 0;
|
|
192
290
|
let weekly = 0;
|
|
193
291
|
let monthly = 0;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
292
|
+
while (filePos > 0 && !shouldStop) {
|
|
293
|
+
const readLength = Math.min(CHUNK_SIZE, filePos);
|
|
294
|
+
filePos -= readLength;
|
|
295
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, readLength, filePos);
|
|
296
|
+
const chunkStr = buffer.toString("utf8", 0, bytesRead) + leftover;
|
|
297
|
+
const lines = chunkStr.split("\n");
|
|
298
|
+
// The leftmost line could be cut off, save it for the next chunk read to the left
|
|
299
|
+
leftover = lines[0];
|
|
300
|
+
// Iterate lines in reverse order (from end to start)
|
|
301
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
302
|
+
const line = lines[i].trim();
|
|
303
|
+
if (!line)
|
|
198
304
|
continue;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
305
|
+
try {
|
|
306
|
+
const entry = JSON.parse(line);
|
|
307
|
+
if (entry.type !== "tokens" || !entry.cost)
|
|
308
|
+
continue;
|
|
309
|
+
if (entry._ts < earliest) {
|
|
310
|
+
shouldStop = true;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
if (entry._ts >= budgetTracker.dayStart)
|
|
314
|
+
daily += entry.cost;
|
|
315
|
+
if (entry._ts >= budgetTracker.weekStart)
|
|
316
|
+
weekly += entry.cost;
|
|
317
|
+
if (entry._ts >= budgetTracker.monthStart)
|
|
318
|
+
monthly += entry.cost;
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// Skip malformed lines
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Include the very first line at the top
|
|
326
|
+
if (!shouldStop && leftover.trim()) {
|
|
327
|
+
try {
|
|
328
|
+
const entry = JSON.parse(leftover.trim());
|
|
329
|
+
if (entry.type === "tokens" && entry.cost && entry._ts >= earliest) {
|
|
330
|
+
if (entry._ts >= budgetTracker.dayStart)
|
|
331
|
+
daily += entry.cost;
|
|
332
|
+
if (entry._ts >= budgetTracker.weekStart)
|
|
333
|
+
weekly += entry.cost;
|
|
334
|
+
if (entry._ts >= budgetTracker.monthStart)
|
|
335
|
+
monthly += entry.cost;
|
|
336
|
+
}
|
|
205
337
|
}
|
|
206
338
|
catch { }
|
|
207
339
|
}
|
|
@@ -209,7 +341,17 @@ function initBudgetTracker() {
|
|
|
209
341
|
budgetTracker.weeklySpent = weekly;
|
|
210
342
|
budgetTracker.monthlySpent = monthly;
|
|
211
343
|
}
|
|
212
|
-
catch {
|
|
344
|
+
catch (err) {
|
|
345
|
+
// Keep budgetTracker at 0 on error
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
if (fileHandle) {
|
|
349
|
+
try {
|
|
350
|
+
await fileHandle.close();
|
|
351
|
+
}
|
|
352
|
+
catch { }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
213
355
|
budgetTracker.initialized = true;
|
|
214
356
|
}
|
|
215
357
|
/**
|
|
@@ -297,8 +439,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
297
439
|
lastConfigMtime = statSync(CONFIG_FILE).mtimeMs;
|
|
298
440
|
}
|
|
299
441
|
// Initialize in-memory budget tracker (reads JSONL once)
|
|
300
|
-
initBudgetTracker();
|
|
301
|
-
|
|
442
|
+
await initBudgetTracker();
|
|
443
|
+
// 不再写 type:"init" 标记:OpenCode 会在多个子进程(LSP、工具 runner 等)独立加载
|
|
444
|
+
// plugin,每次启动会向 JSONL 写多份重复的 init 行,污染日志且无计费价值。
|
|
445
|
+
// 跨进程去重的根因修复留待后续版本。
|
|
302
446
|
// Show config validation warnings via Toast
|
|
303
447
|
if (configWarnings.length > 0) {
|
|
304
448
|
try {
|
|
@@ -323,6 +467,14 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
323
467
|
const info = props?.info;
|
|
324
468
|
if (!info?.tokens)
|
|
325
469
|
return;
|
|
470
|
+
// 流式中间态保护:仅在消息真正完结时记账,避免对同一条消息重复计费。
|
|
471
|
+
// 完结信号优先用 time.completed;实证发现 provider 的 finish reason
|
|
472
|
+
// 会在 time.completed 之前一帧出现且此时 tokens 已完整,因此把 finish
|
|
473
|
+
// 也作为有效的完结信号,避免极端断流时漏掉最后一帧。
|
|
474
|
+
// 通过 modelID 识别 AI 生成消息(user 消息无此字段),不再硬编码 role。
|
|
475
|
+
if (info.modelID && !info.time?.completed && !info.finish) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
326
478
|
const messageId = info.id;
|
|
327
479
|
const sessionId = info.sessionID;
|
|
328
480
|
if (!messageId || !sessionId)
|
|
@@ -332,10 +484,10 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
332
484
|
const reasoning = info.tokens.reasoning ?? 0;
|
|
333
485
|
const cacheRead = info.tokens.cache?.read ?? 0;
|
|
334
486
|
const cacheWrite = info.tokens.cache?.write ?? 0;
|
|
335
|
-
const hasTokens = input > 0 || output > 0;
|
|
487
|
+
const hasTokens = input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0;
|
|
336
488
|
if (!hasTokens)
|
|
337
489
|
return;
|
|
338
|
-
const dedupeKey = `${messageId}-${input}-${output}`;
|
|
490
|
+
const dedupeKey = `${messageId}-${input}-${output}-${cacheRead}-${cacheWrite}`;
|
|
339
491
|
if (isDuplicate(dedupeKey))
|
|
340
492
|
return;
|
|
341
493
|
const model = info.model?.modelID ?? info.modelID ?? "unknown";
|
package/dist/test/shared.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import { strict as assert } from "node:assert";
|
|
3
3
|
import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfWeek, getStartOfMonth, validateConfig, } from "../lib/shared.js";
|
|
4
|
+
import { getProviderFamily, calculateCost } from "../index.js";
|
|
4
5
|
// ============================================================================
|
|
5
6
|
// formatCost
|
|
6
7
|
// ============================================================================
|
|
@@ -376,3 +377,81 @@ describe("findModelConfigPricing", () => {
|
|
|
376
377
|
assert.deepEqual(findModelConfigPricing(result.config.models, "prefix/my-model", "any-provider"), { input: 1, output: 2 });
|
|
377
378
|
});
|
|
378
379
|
});
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// getProviderFamily
|
|
382
|
+
// ============================================================================
|
|
383
|
+
describe("getProviderFamily", () => {
|
|
384
|
+
it("should detect anthropic for various combinations", () => {
|
|
385
|
+
assert.equal(getProviderFamily("claude-opus-4.6", "openai"), "anthropic");
|
|
386
|
+
assert.equal(getProviderFamily("some-model", "anthropic"), "anthropic");
|
|
387
|
+
assert.equal(getProviderFamily("claude-3-5-sonnet", "openrouter"), "anthropic");
|
|
388
|
+
});
|
|
389
|
+
it("should detect openai for various combinations", () => {
|
|
390
|
+
assert.equal(getProviderFamily("gpt-4o", "unknown"), "openai");
|
|
391
|
+
assert.equal(getProviderFamily("o1-mini", "openrouter"), "openai");
|
|
392
|
+
assert.equal(getProviderFamily("o3", "together"), "openai");
|
|
393
|
+
assert.equal(getProviderFamily("some-model", "openai"), "openai");
|
|
394
|
+
});
|
|
395
|
+
it("should detect deepseek for various combinations", () => {
|
|
396
|
+
assert.equal(getProviderFamily("deepseek-chat", "siliconflow"), "deepseek");
|
|
397
|
+
assert.equal(getProviderFamily("deepseek-reasoner", "deepseek"), "deepseek");
|
|
398
|
+
assert.equal(getProviderFamily("deepseek/deepseek-r1", "openrouter"), "deepseek");
|
|
399
|
+
});
|
|
400
|
+
it("should detect google for various combinations", () => {
|
|
401
|
+
assert.equal(getProviderFamily("gemini-2.5-pro", "openrouter"), "google");
|
|
402
|
+
assert.equal(getProviderFamily("some-model", "google"), "google");
|
|
403
|
+
assert.equal(getProviderFamily("gemini-2.0-flash", "vertex"), "google");
|
|
404
|
+
});
|
|
405
|
+
it("should fallback to other for unknown providers/models", () => {
|
|
406
|
+
assert.equal(getProviderFamily("meta-llama-3-8b", "together"), "other");
|
|
407
|
+
assert.equal(getProviderFamily("unknown", "unknown"), "other");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
// ============================================================================
|
|
411
|
+
// calculateCost (provider-specific defaults verification)
|
|
412
|
+
// ============================================================================
|
|
413
|
+
describe("calculateCost", () => {
|
|
414
|
+
it("should apply Anthropic defaults correctly (cacheRead = 10%, cacheWrite = 125%)", () => {
|
|
415
|
+
// claude-sonnet-4 base: input = 3, output = 15.
|
|
416
|
+
// 1M inputs, 1M outputs, 100K cacheRead, 100K cacheWrite
|
|
417
|
+
const cost = calculateCost("claude-sonnet-4", "anthropic", 1_000_000, 1_000_000, 100_000, 100_000);
|
|
418
|
+
// Expected details:
|
|
419
|
+
// billable input = 1M - 100K = 900K tokens = 0.9 * 3.00 = $2.70
|
|
420
|
+
// output = 1M tokens = 1.0 * 15.00 = $15.00
|
|
421
|
+
// cacheRead = 100K tokens = 0.1 * 3.00 * 0.1 = $0.03 (10% discount rate)
|
|
422
|
+
// cacheWrite = 100K tokens = 0.1 * 3.00 * 1.25 = $0.375 (125% rate)
|
|
423
|
+
// total = 2.70 + 15.00 + 0.03 + 0.375 = $18.105
|
|
424
|
+
assert.ok(Math.abs(cost - 18.105) < 0.0001, `expected 18.105, got ${cost}`);
|
|
425
|
+
});
|
|
426
|
+
it("should apply OpenAI defaults correctly (cacheRead = 50%, cacheWrite = 0%)", () => {
|
|
427
|
+
// Built-in gpt-4o has input: 2.5, output: 10.
|
|
428
|
+
// 1M inputs, 1M outputs, 100K cacheRead, 100K cacheWrite
|
|
429
|
+
const cost = calculateCost("gpt-4o", "openai", 1_000_000, 1_000_000, 100_000, 100_000);
|
|
430
|
+
// Expected details:
|
|
431
|
+
// billable input = 1M - 100K = 900K tokens = 0.9 * 2.50 = $2.25
|
|
432
|
+
// output = 1M tokens = 1.0 * 10.00 = $10.00
|
|
433
|
+
// cacheRead = 100K tokens = 0.1 * 2.50 * 0.5 = $0.125 (50% discount rate)
|
|
434
|
+
// cacheWrite = 100K tokens = 0.1 * 2.50 * 0 = $0 (free)
|
|
435
|
+
// total = 2.25 + 10.00 + 0.125 + 0 = $12.375
|
|
436
|
+
assert.ok(Math.abs(cost - 12.375) < 0.0001, `expected 12.375, got ${cost}`);
|
|
437
|
+
});
|
|
438
|
+
it("should apply DeepSeek defaults correctly (cacheRead = 10%, cacheWrite = 0%)", () => {
|
|
439
|
+
// deepseek-chat has input: 0.28, output: 0.42. cacheRead: 0.028 (explicit in table)
|
|
440
|
+
const costExplicit = calculateCost("deepseek-chat", "deepseek", 1_000_000, 1_000_000, 100_000, 100_000);
|
|
441
|
+
// billable input = 900K = 0.9 * 0.28 = $0.252
|
|
442
|
+
// output = 1M = 1.0 * 0.42 = $0.42
|
|
443
|
+
// cacheRead = 100K = 0.1 * 0.028 = $0.0028
|
|
444
|
+
// cacheWrite = 100K = 0.1 * 0 = $0
|
|
445
|
+
// total = 0.252 + 0.42 + 0.0028 = $0.6748
|
|
446
|
+
assert.ok(Math.abs(costExplicit - 0.6748) < 0.0001, `expected 0.6748, got ${costExplicit}`);
|
|
447
|
+
// Verify fallback using non-builtin model under deepseek family:
|
|
448
|
+
const costFallback = calculateCost("deepseek-custom", "deepseek", 1_000_000, 1_000_000, 100_000, 100_000);
|
|
449
|
+
// Default base: input = 1, output = 4.
|
|
450
|
+
// billable input = 900K = 0.9 * 1.00 = $0.90
|
|
451
|
+
// output = 1M = 1.0 * 4.00 = $4.00
|
|
452
|
+
// cacheRead = 100K = 0.1 * 1.00 * 0.1 = $0.01 (10% rate fallback)
|
|
453
|
+
// cacheWrite = 100K = 0.1 * 1.00 * 0 = $0 (free cache write)
|
|
454
|
+
// total = 0.90 + 4.00 + 0.01 = $4.91
|
|
455
|
+
assert.ok(Math.abs(costFallback - 4.91) < 0.0001, `expected 4.91, got ${costFallback}`);
|
|
456
|
+
});
|
|
457
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-token-tracker",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.6",
|
|
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,11 +8,6 @@
|
|
|
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
|
-
},
|
|
16
11
|
"keywords": [
|
|
17
12
|
"opencode",
|
|
18
13
|
"plugin",
|
|
@@ -51,5 +46,9 @@
|
|
|
51
46
|
],
|
|
52
47
|
"engines": {
|
|
53
48
|
"node": ">=18.0.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc",
|
|
52
|
+
"test": "tsc && node --test dist/test/shared.test.js"
|
|
54
53
|
}
|
|
55
|
-
}
|
|
54
|
+
}
|