openuse 0.1.0

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 ADDED
@@ -0,0 +1,34 @@
1
+ # opencode-costs
2
+
3
+ Estimate OpenCode token costs per day and per model by combining:
4
+
5
+ - local OpenCode usage from SQLite (`part` + `message` tables)
6
+ - live OpenRouter model pricing from `https://openrouter.ai/api/v1/models`
7
+
8
+ ## Run
9
+
10
+ ```bash
11
+ npm install
12
+ npm start
13
+ ```
14
+
15
+ Use a custom DB path:
16
+
17
+ ```bash
18
+ npm start -- "C:\\Users\\Sayad\\.local\\share\\opencode\\opencode.db"
19
+ ```
20
+
21
+ Or with env var:
22
+
23
+ ```bash
24
+ OPENCODE_DB_PATH="C:\\Users\\Sayad\\.local\\share\\opencode\\opencode.db" npm start
25
+ ```
26
+
27
+ ## Output
28
+
29
+ The script prints:
30
+
31
+ - per day/model table with input/output/cache tokens and estimated cost
32
+ - per day totals table
33
+
34
+ Rows that cannot be matched to an OpenRouter model are marked as `unmatched` and excluded from cost totals.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "openuse",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "start": "tsx src/index.ts",
7
+ "check": "tsc --noEmit"
8
+ },
9
+ "dependencies": {
10
+ "better-sqlite3": "^12.4.1"
11
+ },
12
+ "devDependencies": {
13
+ "@types/better-sqlite3": "^7.6.13",
14
+ "@types/node": "^25.5.0",
15
+ "tsx": "^4.20.6",
16
+ "typescript": "^5.9.3"
17
+ }
18
+ }
@@ -0,0 +1,3 @@
1
+ allowBuilds:
2
+ better-sqlite3: true
3
+ esbuild: true
package/src/index.ts ADDED
@@ -0,0 +1,281 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import Database from "better-sqlite3";
4
+
5
+ type UsageRow = {
6
+ day: string;
7
+ model: string;
8
+ input_tokens: number;
9
+ output_tokens: number;
10
+ reasoning_tokens: number;
11
+ cache_read_tokens: number;
12
+ cache_write_tokens: number;
13
+ total_tokens: number;
14
+ steps: number;
15
+ };
16
+
17
+ type OpenRouterModel = {
18
+ id: string;
19
+ name?: string;
20
+ pricing?: Record<string, string>;
21
+ };
22
+
23
+ type PricedRow = UsageRow & {
24
+ matched_model_id: string | null;
25
+ prompt_rate: number;
26
+ completion_rate: number;
27
+ cache_read_rate: number;
28
+ cache_write_rate: number;
29
+ cost_usd: number | null;
30
+ };
31
+
32
+ const dbPath = process.argv[2] ?? process.env.OPENCODE_DB_PATH ?? path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
33
+
34
+ function normalize(value: string) {
35
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
36
+ }
37
+
38
+ function tokenize(value: string) {
39
+ return value
40
+ .toLowerCase()
41
+ .replace(/([a-z])([0-9])/g, "$1 $2")
42
+ .replace(/([0-9])([a-z])/g, "$1 $2")
43
+ .replace(/[^a-z0-9]+/g, " ")
44
+ .trim()
45
+ .split(/\s+/)
46
+ .filter(Boolean);
47
+ }
48
+
49
+ function readUsage(file: string) {
50
+ const db = new Database(file, { readonly: true });
51
+ const sql = `
52
+ SELECT
53
+ date(datetime(json_extract(m.data, '$.time.created') / 1000, 'unixepoch', 'localtime')) AS day,
54
+ COALESCE(json_extract(m.data, '$.modelID'), 'unknown') AS model,
55
+ SUM(COALESCE(json_extract(p.data, '$.tokens.input'), 0)) AS input_tokens,
56
+ SUM(COALESCE(json_extract(p.data, '$.tokens.output'), 0)) AS output_tokens,
57
+ SUM(COALESCE(json_extract(p.data, '$.tokens.reasoning'), 0)) AS reasoning_tokens,
58
+ SUM(COALESCE(json_extract(p.data, '$.tokens.cache.read'), 0)) AS cache_read_tokens,
59
+ SUM(COALESCE(json_extract(p.data, '$.tokens.cache.write'), 0)) AS cache_write_tokens,
60
+ SUM(COALESCE(json_extract(p.data, '$.tokens.total'), 0)) AS total_tokens,
61
+ COUNT(*) AS steps
62
+ FROM part p
63
+ JOIN message m ON m.id = p.message_id
64
+ WHERE json_extract(p.data, '$.type') = 'step-finish'
65
+ AND json_extract(m.data, '$.time.created') IS NOT NULL
66
+ GROUP BY day, model
67
+ ORDER BY day DESC, total_tokens DESC
68
+ `;
69
+ const rows = db.prepare(sql).all() as UsageRow[];
70
+ db.close();
71
+ return rows;
72
+ }
73
+
74
+ async function fetchOpenRouterModels() {
75
+ const response = await fetch("https://openrouter.ai/api/v1/models");
76
+ if (!response.ok) {
77
+ throw new Error(`OpenRouter API failed with ${response.status}`);
78
+ }
79
+ const payload = (await response.json()) as { data?: OpenRouterModel[] };
80
+ return payload.data ?? [];
81
+ }
82
+
83
+ function buildModelIndex(models: OpenRouterModel[]) {
84
+ const byId = new Map<string, OpenRouterModel>();
85
+ const byNormalized = new Map<string, OpenRouterModel>();
86
+ const candidates: { model: OpenRouterModel; normalizedKeys: string[]; tokenKeys: string[][] }[] = [];
87
+
88
+ for (const model of models) {
89
+ byId.set(model.id, model);
90
+ const keys = [
91
+ model.id,
92
+ model.name ?? "",
93
+ model.id.split("/").at(-1) ?? "",
94
+ (model.name ?? "").split(":").at(-1)?.trim() ?? ""
95
+ ];
96
+ const normalizedKeys: string[] = [];
97
+ const tokenKeys: string[][] = [];
98
+
99
+ for (const key of keys) {
100
+ if (!key) {
101
+ continue;
102
+ }
103
+ const normalizedKey = normalize(key);
104
+ normalizedKeys.push(normalizedKey);
105
+ tokenKeys.push(tokenize(key));
106
+ if (!byNormalized.has(normalizedKey)) {
107
+ byNormalized.set(normalizedKey, model);
108
+ }
109
+ }
110
+
111
+ candidates.push({ model, normalizedKeys, tokenKeys });
112
+ }
113
+
114
+ return { byId, byNormalized, candidates };
115
+ }
116
+
117
+ function matchModel(inputModel: string, index: ReturnType<typeof buildModelIndex>) {
118
+ if (index.byId.has(inputModel)) {
119
+ return index.byId.get(inputModel) ?? null;
120
+ }
121
+
122
+ const normalized = normalize(inputModel);
123
+ if (index.byNormalized.has(normalized)) {
124
+ return index.byNormalized.get(normalized) ?? null;
125
+ }
126
+
127
+ let best: { model: OpenRouterModel; score: number } | null = null;
128
+ const inputTokens = new Set(tokenize(inputModel));
129
+
130
+ for (const candidate of index.candidates) {
131
+ let score = 0;
132
+
133
+ for (const key of candidate.normalizedKeys) {
134
+ if (!key) {
135
+ continue;
136
+ }
137
+ if (key.includes(normalized) || normalized.includes(key)) {
138
+ const ratio = Math.min(key.length, normalized.length) / Math.max(key.length, normalized.length);
139
+ if (ratio > score) {
140
+ score = ratio;
141
+ }
142
+ }
143
+ }
144
+
145
+ for (const keyTokens of candidate.tokenKeys) {
146
+ if (keyTokens.length === 0 || inputTokens.size === 0) {
147
+ continue;
148
+ }
149
+ let overlap = 0;
150
+ for (const token of keyTokens) {
151
+ if (inputTokens.has(token)) {
152
+ overlap += 1;
153
+ }
154
+ }
155
+ const tokenScore = overlap / Math.max(keyTokens.length, inputTokens.size);
156
+ if (tokenScore > score) {
157
+ score = tokenScore;
158
+ }
159
+ }
160
+
161
+ if (!best || score > best.score) {
162
+ best = { model: candidate.model, score };
163
+ }
164
+ }
165
+
166
+ return best && best.score >= 0.7 ? best.model : null;
167
+ }
168
+
169
+ function toNumber(value: string | undefined) {
170
+ if (!value) {
171
+ return 0;
172
+ }
173
+ const parsed = Number(value);
174
+ return Number.isFinite(parsed) ? parsed : 0;
175
+ }
176
+
177
+ function priceRows(rows: UsageRow[], models: OpenRouterModel[]) {
178
+ const index = buildModelIndex(models);
179
+
180
+ return rows.map((row) => {
181
+ const matched = matchModel(row.model, index);
182
+ if (!matched) {
183
+ return {
184
+ ...row,
185
+ matched_model_id: null,
186
+ prompt_rate: 0,
187
+ completion_rate: 0,
188
+ cache_read_rate: 0,
189
+ cache_write_rate: 0,
190
+ cost_usd: null
191
+ } satisfies PricedRow;
192
+ }
193
+
194
+ const pricing = matched.pricing ?? {};
195
+ const promptRate = toNumber(pricing.prompt);
196
+ const completionRate = toNumber(pricing.completion);
197
+ const cacheReadRate = toNumber(pricing.input_cache_read);
198
+ const cacheWriteRate = toNumber(pricing.input_cache_write || pricing.input_cache_creation || pricing.cache_write);
199
+
200
+ const costUsd =
201
+ row.input_tokens * promptRate +
202
+ row.output_tokens * completionRate +
203
+ row.cache_read_tokens * cacheReadRate +
204
+ row.cache_write_tokens * cacheWriteRate;
205
+
206
+ return {
207
+ ...row,
208
+ matched_model_id: matched.id,
209
+ prompt_rate: promptRate,
210
+ completion_rate: completionRate,
211
+ cache_read_rate: cacheReadRate,
212
+ cache_write_rate: cacheWriteRate,
213
+ cost_usd: costUsd
214
+ } satisfies PricedRow;
215
+ });
216
+ }
217
+
218
+ function formatMoney(value: number | null) {
219
+ if (value === null) {
220
+ return null;
221
+ }
222
+ return Number(value.toFixed(6));
223
+ }
224
+
225
+ function printReport(rows: PricedRow[]) {
226
+ const detail = rows.map((row) => ({
227
+ day: row.day,
228
+ model: row.model,
229
+ matched: row.matched_model_id ?? "unmatched",
230
+ input: row.input_tokens,
231
+ output: row.output_tokens,
232
+ cache_read: row.cache_read_tokens,
233
+ cache_write: row.cache_write_tokens,
234
+ total_tokens: row.total_tokens,
235
+ cost_usd: formatMoney(row.cost_usd)
236
+ }));
237
+
238
+ const totalsByDay = new Map<string, { cost: number; totalTokens: number; unmatchedRows: number }>();
239
+ for (const row of rows) {
240
+ const current = totalsByDay.get(row.day) ?? { cost: 0, totalTokens: 0, unmatchedRows: 0 };
241
+ current.totalTokens += row.total_tokens;
242
+ if (row.cost_usd === null) {
243
+ current.unmatchedRows += 1;
244
+ } else {
245
+ current.cost += row.cost_usd;
246
+ }
247
+ totalsByDay.set(row.day, current);
248
+ }
249
+
250
+ const daily = [...totalsByDay.entries()]
251
+ .sort((a, b) => b[0].localeCompare(a[0]))
252
+ .map(([day, value]) => ({
253
+ day,
254
+ total_tokens: value.totalTokens,
255
+ estimated_cost_usd: Number(value.cost.toFixed(6)),
256
+ unmatched_models: value.unmatchedRows
257
+ }));
258
+
259
+ console.log(`Database: ${dbPath}`);
260
+ console.log("\nPer day/model:");
261
+ console.table(detail);
262
+ console.log("Daily totals:");
263
+ console.table(daily);
264
+ }
265
+
266
+ async function main() {
267
+ const usageRows = readUsage(dbPath);
268
+ if (usageRows.length === 0) {
269
+ console.log(`No usage rows found in ${dbPath}`);
270
+ return;
271
+ }
272
+ const models = await fetchOpenRouterModels();
273
+ const pricedRows = priceRows(usageRows, models);
274
+ printReport(pricedRows);
275
+ }
276
+
277
+ main().catch((error: unknown) => {
278
+ const message = error instanceof Error ? error.message : String(error);
279
+ console.error(`Failed: ${message}`);
280
+ process.exit(1);
281
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "noEmit": true
9
+ },
10
+ "include": ["src"]
11
+ }