@hasna/economy 0.2.20 → 0.2.21
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/cli/commands/completion.d.ts +2 -0
- package/dist/cli/commands/completion.d.ts.map +1 -0
- package/dist/cli/commands/extras.d.ts +4 -0
- package/dist/cli/commands/extras.d.ts.map +1 -0
- package/dist/cli/commands/menubar.d.ts.map +1 -1
- package/dist/cli/commands/notification.d.ts +8 -0
- package/dist/cli/commands/notification.d.ts.map +1 -0
- package/dist/cli/commands/todos.d.ts +26 -0
- package/dist/cli/commands/todos.d.ts.map +1 -0
- package/dist/cli/commands/tui.d.ts +10 -0
- package/dist/cli/commands/tui.d.ts.map +1 -0
- package/dist/cli/commands/watch.d.ts +1 -0
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/index.js +5134 -641
- package/dist/db/database.d.ts +41 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1202 -135
- package/dist/ingest/billing.d.ts +27 -0
- package/dist/ingest/billing.d.ts.map +1 -0
- package/dist/ingest/claude-quota.d.ts +5 -0
- package/dist/ingest/claude-quota.d.ts.map +1 -0
- package/dist/ingest/claude.d.ts +13 -2
- package/dist/ingest/claude.d.ts.map +1 -1
- package/dist/ingest/codex-quota.d.ts +5 -0
- package/dist/ingest/codex-quota.d.ts.map +1 -0
- package/dist/ingest/codex.d.ts +2 -1
- package/dist/ingest/codex.d.ts.map +1 -1
- package/dist/ingest/cursor.d.ts +6 -0
- package/dist/ingest/cursor.d.ts.map +1 -0
- package/dist/ingest/gemini.d.ts +2 -1
- package/dist/ingest/gemini.d.ts.map +1 -1
- package/dist/ingest/hermes.d.ts +6 -0
- package/dist/ingest/hermes.d.ts.map +1 -0
- package/dist/ingest/opencode.d.ts +7 -0
- package/dist/ingest/opencode.d.ts.map +1 -0
- package/dist/ingest/otel.d.ts +20 -0
- package/dist/ingest/otel.d.ts.map +1 -0
- package/dist/ingest/pi.d.ts +7 -0
- package/dist/ingest/pi.d.ts.map +1 -0
- package/dist/ingest/plugin.d.ts +17 -0
- package/dist/ingest/plugin.d.ts.map +1 -0
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.d.ts.map +1 -0
- package/dist/lib/billing-diff.d.ts +22 -0
- package/dist/lib/billing-diff.d.ts.map +1 -0
- package/dist/lib/cloud-sync.d.ts +35 -0
- package/dist/lib/cloud-sync.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/gatherer.d.ts.map +1 -1
- package/dist/lib/model-config.d.ts.map +1 -1
- package/dist/lib/open-projects.d.ts +19 -0
- package/dist/lib/open-projects.d.ts.map +1 -0
- package/dist/lib/package-metadata.d.ts +8 -0
- package/dist/lib/package-metadata.d.ts.map +1 -0
- package/dist/lib/paths.d.ts +20 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/pricing.d.ts +3 -3
- package/dist/lib/pricing.d.ts.map +1 -1
- package/dist/lib/savings.d.ts +17 -0
- package/dist/lib/savings.d.ts.map +1 -0
- package/dist/lib/serve-auth.d.ts +4 -0
- package/dist/lib/serve-auth.d.ts.map +1 -0
- package/dist/lib/spikes.d.ts +18 -0
- package/dist/lib/spikes.d.ts.map +1 -0
- package/dist/lib/sync-all.d.ts +28 -0
- package/dist/lib/sync-all.d.ts.map +1 -0
- package/dist/lib/watch-paths.d.ts +3 -0
- package/dist/lib/watch-paths.d.ts.map +1 -0
- package/dist/lib/webhooks.d.ts +1 -1
- package/dist/lib/webhooks.d.ts.map +1 -1
- package/dist/mcp/http.d.ts +13 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.js +2752 -490
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/otel/index.d.ts +3 -0
- package/dist/otel/index.d.ts.map +1 -0
- package/dist/otel/index.js +1372 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +3095 -201
- package/dist/server/serve.d.ts +10 -2
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/types/index.d.ts +59 -6
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
1
2
|
// @bun
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __returnValue = (v) => v;
|
|
@@ -28,88 +29,503 @@ __export(exports_pricing, {
|
|
|
28
29
|
DEFAULT_PRICING: () => DEFAULT_PRICING
|
|
29
30
|
});
|
|
30
31
|
function normalizeModelName(raw) {
|
|
31
|
-
return raw.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "")
|
|
32
|
+
return raw.trim().toLowerCase().replace(/^models\//, "").replace(/^[a-z0-9_.-]+\//, "").replace(/:.+$/, "").replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
|
|
33
|
+
}
|
|
34
|
+
function normalizeModelNamePreservingProvider(raw) {
|
|
35
|
+
return raw.trim().toLowerCase().replace(/^models\//, "").replace(/:.+$/, "").replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
|
|
36
|
+
}
|
|
37
|
+
function modelLookupKeys(raw) {
|
|
38
|
+
const withProvider = normalizeModelNamePreservingProvider(raw);
|
|
39
|
+
const withoutProvider = normalizeModelName(raw);
|
|
40
|
+
return withProvider === withoutProvider ? [withoutProvider] : [withProvider, withoutProvider];
|
|
41
|
+
}
|
|
42
|
+
function bestPrefixMatch(normalized, entries) {
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const [key] = entry;
|
|
46
|
+
if (normalized !== key && !normalized.startsWith(`${key}-`))
|
|
47
|
+
continue;
|
|
48
|
+
if (!best || key.length > best[0].length)
|
|
49
|
+
best = entry;
|
|
50
|
+
}
|
|
51
|
+
return best?.[1] ?? null;
|
|
52
|
+
}
|
|
53
|
+
function bestModelMatch(model, entries) {
|
|
54
|
+
for (const key of modelLookupKeys(model)) {
|
|
55
|
+
const match = bestPrefixMatch(key, entries);
|
|
56
|
+
if (match)
|
|
57
|
+
return match;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
function exactModelMatch(model, entries) {
|
|
62
|
+
for (const key of modelLookupKeys(model)) {
|
|
63
|
+
const match = entries.find(([entryKey]) => entryKey === key);
|
|
64
|
+
if (match)
|
|
65
|
+
return match[1];
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
32
68
|
}
|
|
33
69
|
function ensurePricingSeeded(db) {
|
|
34
70
|
seedModelPricing(db, DEFAULT_PRICING);
|
|
71
|
+
repairLegacySeededPricing(db);
|
|
72
|
+
repairMissingDefaultCacheWrite1h(db);
|
|
73
|
+
repairMissingDefaultCacheStorage(db);
|
|
74
|
+
removeDeprecatedDefaultPricing(db);
|
|
75
|
+
}
|
|
76
|
+
function repairLegacySeededPricing(db) {
|
|
77
|
+
const now = new Date().toISOString();
|
|
78
|
+
const legacyModels = new Set([
|
|
79
|
+
...Object.keys(LEGACY_DEFAULT_PRICING),
|
|
80
|
+
...Object.keys(ADDITIONAL_LEGACY_DEFAULT_PRICING)
|
|
81
|
+
]);
|
|
82
|
+
for (const model of legacyModels) {
|
|
83
|
+
const current = getModelPricing(db, model);
|
|
84
|
+
const next = DEFAULT_PRICING[model];
|
|
85
|
+
if (!current || !next)
|
|
86
|
+
continue;
|
|
87
|
+
const legacy = LEGACY_DEFAULT_PRICING[model];
|
|
88
|
+
const legacyRows = [
|
|
89
|
+
...legacy ? [legacy] : [],
|
|
90
|
+
...ADDITIONAL_LEGACY_DEFAULT_PRICING[model] ?? []
|
|
91
|
+
];
|
|
92
|
+
if (!legacyRows.some((row) => samePricing(current, row)))
|
|
93
|
+
continue;
|
|
94
|
+
upsertModelPricing(db, {
|
|
95
|
+
model,
|
|
96
|
+
input_per_1m: next.inputPer1M,
|
|
97
|
+
output_per_1m: next.outputPer1M,
|
|
98
|
+
cache_read_per_1m: next.cacheReadPer1M,
|
|
99
|
+
cache_write_per_1m: next.cacheWritePer1M,
|
|
100
|
+
cache_write_1h_per_1m: next.cacheWrite1hPer1M ?? 0,
|
|
101
|
+
cache_storage_per_1m_hour: next.cacheStoragePer1MHour ?? 0,
|
|
102
|
+
updated_at: now
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function repairMissingDefaultCacheWrite1h(db) {
|
|
107
|
+
const now = new Date().toISOString();
|
|
108
|
+
for (const [model, next] of Object.entries(DEFAULT_PRICING)) {
|
|
109
|
+
if (!next.cacheWrite1hPer1M)
|
|
110
|
+
continue;
|
|
111
|
+
const current = getModelPricing(db, model);
|
|
112
|
+
if (!current)
|
|
113
|
+
continue;
|
|
114
|
+
if ((current.cache_write_1h_per_1m ?? 0) !== 0)
|
|
115
|
+
continue;
|
|
116
|
+
if (!sameBasePricing(current, next))
|
|
117
|
+
continue;
|
|
118
|
+
upsertModelPricing(db, {
|
|
119
|
+
model,
|
|
120
|
+
input_per_1m: current.input_per_1m,
|
|
121
|
+
output_per_1m: current.output_per_1m,
|
|
122
|
+
cache_read_per_1m: current.cache_read_per_1m,
|
|
123
|
+
cache_write_per_1m: current.cache_write_per_1m,
|
|
124
|
+
cache_write_1h_per_1m: next.cacheWrite1hPer1M,
|
|
125
|
+
cache_storage_per_1m_hour: current.cache_storage_per_1m_hour ?? next.cacheStoragePer1MHour ?? 0,
|
|
126
|
+
updated_at: now
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function repairMissingDefaultCacheStorage(db) {
|
|
131
|
+
const now = new Date().toISOString();
|
|
132
|
+
for (const [model, next] of Object.entries(DEFAULT_PRICING)) {
|
|
133
|
+
if (!next.cacheStoragePer1MHour)
|
|
134
|
+
continue;
|
|
135
|
+
const current = getModelPricing(db, model);
|
|
136
|
+
if (!current)
|
|
137
|
+
continue;
|
|
138
|
+
if ((current.cache_storage_per_1m_hour ?? 0) !== 0)
|
|
139
|
+
continue;
|
|
140
|
+
if (!sameBasePricing(current, next))
|
|
141
|
+
continue;
|
|
142
|
+
upsertModelPricing(db, {
|
|
143
|
+
model,
|
|
144
|
+
input_per_1m: current.input_per_1m,
|
|
145
|
+
output_per_1m: current.output_per_1m,
|
|
146
|
+
cache_read_per_1m: current.cache_read_per_1m,
|
|
147
|
+
cache_write_per_1m: current.cache_write_per_1m,
|
|
148
|
+
cache_write_1h_per_1m: current.cache_write_1h_per_1m ?? next.cacheWrite1hPer1M ?? 0,
|
|
149
|
+
cache_storage_per_1m_hour: next.cacheStoragePer1MHour,
|
|
150
|
+
updated_at: now
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function removeDeprecatedDefaultPricing(db) {
|
|
155
|
+
for (const [model, removedRows] of Object.entries(REMOVED_DEFAULT_PRICING)) {
|
|
156
|
+
const current = getModelPricing(db, model);
|
|
157
|
+
if (!current)
|
|
158
|
+
continue;
|
|
159
|
+
if (!removedRows.some((row) => samePricing(current, row)))
|
|
160
|
+
continue;
|
|
161
|
+
deleteModelPricing(db, model);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function sameBasePricing(row, pricing) {
|
|
165
|
+
return row.input_per_1m === pricing.inputPer1M && row.output_per_1m === pricing.outputPer1M && row.cache_read_per_1m === pricing.cacheReadPer1M && row.cache_write_per_1m === pricing.cacheWritePer1M;
|
|
166
|
+
}
|
|
167
|
+
function samePricing(row, pricing) {
|
|
168
|
+
return row.input_per_1m === pricing.inputPer1M && row.output_per_1m === pricing.outputPer1M && row.cache_read_per_1m === pricing.cacheReadPer1M && row.cache_write_per_1m === pricing.cacheWritePer1M && (row.cache_write_1h_per_1m ?? 0) === (pricing.cacheWrite1hPer1M ?? 0) && (row.cache_storage_per_1m_hour ?? 0) === (pricing.cacheStoragePer1MHour ?? 0);
|
|
35
169
|
}
|
|
36
170
|
function getPricingFromDb(db, model) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
cacheReadPer1M: row.cache_read_per_1m,
|
|
44
|
-
cacheWritePer1M: row.cache_write_per_1m
|
|
45
|
-
};
|
|
171
|
+
if (isFreeModel(model))
|
|
172
|
+
return FREE_PRICING;
|
|
173
|
+
for (const key of modelLookupKeys(model)) {
|
|
174
|
+
const row = getModelPricing(db, key);
|
|
175
|
+
if (row)
|
|
176
|
+
return modelPricingFromDbRow(row);
|
|
46
177
|
}
|
|
47
178
|
const allRows = db.prepare(`SELECT * FROM model_pricing`).all();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
179
|
+
const match = bestModelMatch(model, allRows.map((r) => [r.model, r]));
|
|
180
|
+
if (!match)
|
|
181
|
+
return null;
|
|
182
|
+
return modelPricingFromDbRow(match);
|
|
183
|
+
}
|
|
184
|
+
function modelPricingFromDbRow(row) {
|
|
185
|
+
const seeded = DEFAULT_PRICING[row.model];
|
|
186
|
+
const cacheWrite1hPer1M = seeded?.cacheWrite1hPer1M && (row.cache_write_1h_per_1m ?? 0) === 0 && sameBasePricing(row, seeded) ? seeded.cacheWrite1hPer1M : row.cache_write_1h_per_1m ?? 0;
|
|
187
|
+
return {
|
|
188
|
+
inputPer1M: row.input_per_1m,
|
|
189
|
+
outputPer1M: row.output_per_1m,
|
|
190
|
+
cacheReadPer1M: row.cache_read_per_1m,
|
|
191
|
+
cacheWritePer1M: row.cache_write_per_1m,
|
|
192
|
+
cacheWrite1hPer1M,
|
|
193
|
+
cacheStoragePer1MHour: row.cache_storage_per_1m_hour ?? seeded?.cacheStoragePer1MHour ?? 0
|
|
194
|
+
};
|
|
54
195
|
}
|
|
55
196
|
function getPricing(model) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
return null;
|
|
197
|
+
if (isFreeModel(model))
|
|
198
|
+
return FREE_PRICING;
|
|
199
|
+
return bestModelMatch(model, Object.entries(DEFAULT_PRICING));
|
|
200
|
+
}
|
|
201
|
+
function isFreeModel(model) {
|
|
202
|
+
return model.trim().toLowerCase().endsWith(":free");
|
|
64
203
|
}
|
|
65
|
-
function computeCost(model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0) {
|
|
204
|
+
function computeCost(model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, cacheWrite1hTokens = 0, cacheStorageTokenHours = 0) {
|
|
66
205
|
const pricing = getPricing(model);
|
|
67
206
|
if (!pricing)
|
|
68
207
|
return 0;
|
|
69
|
-
return (
|
|
208
|
+
return computeCostWithPricing(model, pricing, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours);
|
|
70
209
|
}
|
|
71
|
-
function computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0) {
|
|
210
|
+
function computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, cacheWrite1hTokens = 0, cacheStorageTokenHours = 0) {
|
|
72
211
|
const pricing = getPricingFromDb(db, model) ?? getPricing(model);
|
|
73
212
|
if (!pricing)
|
|
74
213
|
return 0;
|
|
75
|
-
return (
|
|
214
|
+
return computeCostWithPricing(model, pricing, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours);
|
|
76
215
|
}
|
|
77
|
-
|
|
216
|
+
function computeCostWithPricing(model, pricing, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours) {
|
|
217
|
+
if (isFreeModel(model))
|
|
218
|
+
return 0;
|
|
219
|
+
let effective = pricing;
|
|
220
|
+
const promptTier = bestModelMatch(model, Object.entries(GEMINI_PROMPT_TIERS)) ?? bestModelMatch(model, Object.entries(QWEN_PROMPT_TIERS)) ?? bestModelMatch(model, Object.entries(MINIMAX_PROMPT_TIERS)) ?? bestModelMatch(model, Object.entries(XAI_PROMPT_TIERS)) ?? exactModelMatch(model, Object.entries(OPENAI_PROMPT_TIERS));
|
|
221
|
+
if (promptTier) {
|
|
222
|
+
const billablePromptTokens = inputTokens + cacheReadTokens + cacheWriteTokens + cacheWrite1hTokens;
|
|
223
|
+
if (billablePromptTokens > promptTier.threshold) {
|
|
224
|
+
effective = {
|
|
225
|
+
...pricing,
|
|
226
|
+
inputPer1M: promptTier.inputPer1M ?? pricing.inputPer1M * (promptTier.inputMultiplier ?? 1),
|
|
227
|
+
outputPer1M: promptTier.outputPer1M ?? pricing.outputPer1M * (promptTier.outputMultiplier ?? 1),
|
|
228
|
+
cacheReadPer1M: promptTier.cacheReadPer1M ?? pricing.cacheReadPer1M * (promptTier.cacheReadMultiplier ?? 1),
|
|
229
|
+
cacheWritePer1M: promptTier.cacheWritePer1M ?? pricing.cacheWritePer1M * (promptTier.cacheWriteMultiplier ?? 1)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return (inputTokens * effective.inputPer1M + outputTokens * effective.outputPer1M + cacheReadTokens * effective.cacheReadPer1M + cacheWriteTokens * effective.cacheWritePer1M + cacheWrite1hTokens * (effective.cacheWrite1hPer1M ?? effective.cacheWritePer1M) + cacheStorageTokenHours * (effective.cacheStoragePer1MHour ?? 0)) / 1e6;
|
|
234
|
+
}
|
|
235
|
+
var DEFAULT_PRICING, LEGACY_DEFAULT_PRICING, ADDITIONAL_LEGACY_DEFAULT_PRICING, REMOVED_DEFAULT_PRICING, FREE_PRICING, GEMINI_PROMPT_TIERS, OPENAI_PROMPT_TIERS, QWEN_PROMPT_TIERS, MINIMAX_PROMPT_TIERS, XAI_PROMPT_TIERS;
|
|
78
236
|
var init_pricing = __esm(() => {
|
|
79
237
|
init_database();
|
|
80
238
|
DEFAULT_PRICING = {
|
|
81
|
-
"claude-opus-4-
|
|
82
|
-
"claude-opus-4-
|
|
83
|
-
"claude-
|
|
84
|
-
"claude-
|
|
85
|
-
"claude-
|
|
86
|
-
"claude-
|
|
239
|
+
"claude-opus-4-7": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25, cacheWrite1hPer1M: 10 },
|
|
240
|
+
"claude-opus-4-6": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25, cacheWrite1hPer1M: 10 },
|
|
241
|
+
"claude-opus-4-5": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25, cacheWrite1hPer1M: 10 },
|
|
242
|
+
"claude-opus-4-1": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75, cacheWrite1hPer1M: 30 },
|
|
243
|
+
"claude-opus-4": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75, cacheWrite1hPer1M: 30 },
|
|
244
|
+
"claude-sonnet-4-6": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 6 },
|
|
245
|
+
"claude-sonnet-4-5": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 6 },
|
|
246
|
+
"claude-sonnet-4": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 6 },
|
|
247
|
+
"claude-3-7-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 6 },
|
|
248
|
+
"claude-haiku-4-5": { inputPer1M: 1, outputPer1M: 5, cacheReadPer1M: 0.1, cacheWritePer1M: 1.25, cacheWrite1hPer1M: 2 },
|
|
249
|
+
"claude-3-5-haiku": { inputPer1M: 0.8, outputPer1M: 4, cacheReadPer1M: 0.08, cacheWritePer1M: 1, cacheWrite1hPer1M: 1.6 },
|
|
250
|
+
"claude-3-opus": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75, cacheWrite1hPer1M: 30 },
|
|
251
|
+
"claude-3-haiku": { inputPer1M: 0.25, outputPer1M: 1.25, cacheReadPer1M: 0.03, cacheWritePer1M: 0.3, cacheWrite1hPer1M: 0.5 },
|
|
252
|
+
"gemini-3.1-pro-preview": { inputPer1M: 2, outputPer1M: 12, cacheReadPer1M: 0.2, cacheWritePer1M: 0, cacheStoragePer1MHour: 4.5 },
|
|
253
|
+
"gemini-3.1-flash-lite-preview": { inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0.025, cacheWritePer1M: 0, cacheStoragePer1MHour: 1 },
|
|
254
|
+
"gemini-3.1-flash-lite": { inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0.025, cacheWritePer1M: 0, cacheStoragePer1MHour: 1 },
|
|
255
|
+
"gemini-3-flash-preview": { inputPer1M: 0.5, outputPer1M: 3, cacheReadPer1M: 0.05, cacheWritePer1M: 0, cacheStoragePer1MHour: 1 },
|
|
256
|
+
"gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.125, cacheWritePer1M: 0, cacheStoragePer1MHour: 4.5 },
|
|
257
|
+
"gemini-2.5-flash": { inputPer1M: 0.3, outputPer1M: 2.5, cacheReadPer1M: 0.03, cacheWritePer1M: 0, cacheStoragePer1MHour: 1 },
|
|
258
|
+
"gemini-2.5-flash-lite": { inputPer1M: 0.1, outputPer1M: 0.4, cacheReadPer1M: 0.01, cacheWritePer1M: 0, cacheStoragePer1MHour: 1 },
|
|
259
|
+
"gemini-2.0-flash": { inputPer1M: 0.1, outputPer1M: 0.4, cacheReadPer1M: 0.025, cacheWritePer1M: 0, cacheStoragePer1MHour: 1 },
|
|
260
|
+
"gemini-2.0-flash-lite": { inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
261
|
+
"google/gemini-3.1-pro-preview": { inputPer1M: 2, outputPer1M: 12, cacheReadPer1M: 0.2, cacheWritePer1M: 0.375 },
|
|
262
|
+
"google/gemini-3.1-flash-lite-preview": { inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0.025, cacheWritePer1M: 0.08333333333333334 },
|
|
263
|
+
"google/gemini-3.1-flash-lite": { inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0.025, cacheWritePer1M: 0.08333333333333334 },
|
|
264
|
+
"google/gemini-3-flash-preview": { inputPer1M: 0.5, outputPer1M: 3, cacheReadPer1M: 0.05, cacheWritePer1M: 0.08333333333333334 },
|
|
265
|
+
"google/gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.125, cacheWritePer1M: 0.375 },
|
|
266
|
+
"google/gemini-2.5-flash": { inputPer1M: 0.3, outputPer1M: 2.5, cacheReadPer1M: 0.03, cacheWritePer1M: 0.08333333333333334 },
|
|
267
|
+
"google/gemini-2.5-flash-lite": { inputPer1M: 0.1, outputPer1M: 0.4, cacheReadPer1M: 0.01, cacheWritePer1M: 0.08333333333333334 },
|
|
268
|
+
"gpt-5.5": { inputPer1M: 5, outputPer1M: 30, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
|
|
269
|
+
"gpt-5.5-pro": { inputPer1M: 30, outputPer1M: 180, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
270
|
+
"gpt-5.4": { inputPer1M: 2.5, outputPer1M: 15, cacheReadPer1M: 0.25, cacheWritePer1M: 0 },
|
|
271
|
+
"gpt-5.4-pro": { inputPer1M: 30, outputPer1M: 180, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
272
|
+
"gpt-5.4-mini": { inputPer1M: 0.75, outputPer1M: 4.5, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
|
|
273
|
+
"gpt-5.4-nano": { inputPer1M: 0.2, outputPer1M: 1.25, cacheReadPer1M: 0.02, cacheWritePer1M: 0 },
|
|
274
|
+
"gpt-5.3-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.175, cacheWritePer1M: 0 },
|
|
275
|
+
"gpt-5.2-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.175, cacheWritePer1M: 0 },
|
|
276
|
+
"gpt-5.2-chat-latest": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.175, cacheWritePer1M: 0 },
|
|
277
|
+
"gpt-5.2": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.175, cacheWritePer1M: 0 },
|
|
278
|
+
"gpt-5-codex": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.125, cacheWritePer1M: 0 },
|
|
279
|
+
"gpt-5-mini": { inputPer1M: 0.25, outputPer1M: 2, cacheReadPer1M: 0.025, cacheWritePer1M: 0 },
|
|
280
|
+
"gpt-5": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.125, cacheWritePer1M: 0 },
|
|
281
|
+
"gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, cacheReadPer1M: 1.25, cacheWritePer1M: 0 },
|
|
282
|
+
"gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
|
|
283
|
+
o1: { inputPer1M: 15, outputPer1M: 60, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
|
|
284
|
+
"o1-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.55, cacheWritePer1M: 0 },
|
|
285
|
+
o3: { inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
|
|
286
|
+
"o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.55, cacheWritePer1M: 0 },
|
|
287
|
+
"o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.275, cacheWritePer1M: 0 },
|
|
288
|
+
"qwen3.6-plus": { inputPer1M: 0.325, outputPer1M: 1.95, cacheReadPer1M: 0.0325, cacheWritePer1M: 0.40625 },
|
|
289
|
+
"qwen3.6-flash": { inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0.025, cacheWritePer1M: 0.3125 },
|
|
290
|
+
"qwen3.6-35b-a3b": { inputPer1M: 0.15, outputPer1M: 1, cacheReadPer1M: 0.05, cacheWritePer1M: 0 },
|
|
291
|
+
"qwen3.6-max-preview": { inputPer1M: 1.04, outputPer1M: 6.24, cacheReadPer1M: 0.104, cacheWritePer1M: 1.3 },
|
|
292
|
+
"qwen3.6-27b": { inputPer1M: 0.32, outputPer1M: 3.2, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
293
|
+
"qwen/qwen3.6-plus": { inputPer1M: 0.325, outputPer1M: 1.95, cacheReadPer1M: 0.0325, cacheWritePer1M: 0.40625 },
|
|
294
|
+
"qwen/qwen3.6-flash": { inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0.025, cacheWritePer1M: 0.3125 },
|
|
295
|
+
"qwen/qwen3.6-35b-a3b": { inputPer1M: 0.15, outputPer1M: 1, cacheReadPer1M: 0.05, cacheWritePer1M: 0 },
|
|
296
|
+
"qwen/qwen3.6-max-preview": { inputPer1M: 1.04, outputPer1M: 6.24, cacheReadPer1M: 0.104, cacheWritePer1M: 1.3 },
|
|
297
|
+
"qwen/qwen3.6-27b": { inputPer1M: 0.32, outputPer1M: 3.2, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
298
|
+
"minimax-m2.7": { inputPer1M: 0.3, outputPer1M: 1.2, cacheReadPer1M: 0.06, cacheWritePer1M: 0.375 },
|
|
299
|
+
"minimax-m2.7-highspeed": { inputPer1M: 0.6, outputPer1M: 2.4, cacheReadPer1M: 0.06, cacheWritePer1M: 0.375 },
|
|
300
|
+
"minimax/minimax-m2.7": { inputPer1M: 0.299, outputPer1M: 1.2, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
301
|
+
"minimax-m1": { inputPer1M: 0.4, outputPer1M: 2.2, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
302
|
+
"minimax/minimax-m1": { inputPer1M: 0.4, outputPer1M: 2.2, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
303
|
+
"grok-4.3": { inputPer1M: 1.25, outputPer1M: 2.5, cacheReadPer1M: 0.2, cacheWritePer1M: 0 },
|
|
304
|
+
"grok-latest": { inputPer1M: 1.25, outputPer1M: 2.5, cacheReadPer1M: 0.2, cacheWritePer1M: 0 },
|
|
305
|
+
"grok-4.20": { inputPer1M: 1.25, outputPer1M: 2.5, cacheReadPer1M: 0.2, cacheWritePer1M: 0 },
|
|
306
|
+
"grok-4-1-fast": { inputPer1M: 0.2, outputPer1M: 0.5, cacheReadPer1M: 0.05, cacheWritePer1M: 0 },
|
|
307
|
+
"grok-4-fast": { inputPer1M: 0.2, outputPer1M: 0.5, cacheReadPer1M: 0.05, cacheWritePer1M: 0 },
|
|
308
|
+
"grok-4": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.75, cacheWritePer1M: 0 },
|
|
309
|
+
"grok-code-fast-1": { inputPer1M: 0.2, outputPer1M: 1.5, cacheReadPer1M: 0.02, cacheWritePer1M: 0 },
|
|
310
|
+
"grok-code-fast": { inputPer1M: 0.2, outputPer1M: 1.5, cacheReadPer1M: 0.02, cacheWritePer1M: 0 },
|
|
311
|
+
"grok-3": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.75, cacheWritePer1M: 0 },
|
|
312
|
+
"grok-3-mini": { inputPer1M: 0.3, outputPer1M: 0.5, cacheReadPer1M: 0.07, cacheWritePer1M: 0 },
|
|
313
|
+
"glm-5.1": { inputPer1M: 1.4, outputPer1M: 4.4, cacheReadPer1M: 0.26, cacheWritePer1M: 0 },
|
|
314
|
+
"glm-5": { inputPer1M: 1, outputPer1M: 3.2, cacheReadPer1M: 0.2, cacheWritePer1M: 0 },
|
|
315
|
+
"z-ai/glm-5.1": { inputPer1M: 1.05, outputPer1M: 3.5, cacheReadPer1M: 0.525, cacheWritePer1M: 0 },
|
|
316
|
+
"z-ai/glm-5": { inputPer1M: 0.6, outputPer1M: 1.92, cacheReadPer1M: 0.12, cacheWritePer1M: 0 },
|
|
317
|
+
"kimi-k2.6": { inputPer1M: 0.95, outputPer1M: 4, cacheReadPer1M: 0.16, cacheWritePer1M: 0 },
|
|
318
|
+
"kimi-k2.5": { inputPer1M: 0.6, outputPer1M: 3, cacheReadPer1M: 0.1, cacheWritePer1M: 0 },
|
|
319
|
+
"kimi-k2": { inputPer1M: 0.6, outputPer1M: 2.5, cacheReadPer1M: 0.15, cacheWritePer1M: 0 },
|
|
320
|
+
"moonshotai/kimi-k2.6": { inputPer1M: 0.75, outputPer1M: 3.5, cacheReadPer1M: 0.15, cacheWritePer1M: 0 },
|
|
321
|
+
"moonshotai/kimi-k2.5": { inputPer1M: 0.44, outputPer1M: 2, cacheReadPer1M: 0.22, cacheWritePer1M: 0 },
|
|
322
|
+
"moonshotai/kimi-k2": { inputPer1M: 0.57, outputPer1M: 2.3, cacheReadPer1M: 0, cacheWritePer1M: 0 }
|
|
323
|
+
};
|
|
324
|
+
LEGACY_DEFAULT_PRICING = {
|
|
87
325
|
"claude-3-5-haiku": { inputPer1M: 1, outputPer1M: 5, cacheReadPer1M: 0.1, cacheWritePer1M: 1.25 },
|
|
88
|
-
"claude-
|
|
89
|
-
"
|
|
90
|
-
"
|
|
326
|
+
"claude-opus-4": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
|
|
327
|
+
"gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.31, cacheWritePer1M: 0 },
|
|
328
|
+
"gemini-2.5-flash": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
91
329
|
"gemini-2.0-flash": { inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
92
|
-
"gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
93
|
-
"gemini-1.5-pro": { inputPer1M: 1.25, outputPer1M: 5, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
94
|
-
"gemini-1.5-flash": { inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
95
330
|
"gpt-5.3-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
|
|
96
331
|
"gpt-5.2-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
|
|
97
332
|
"gpt-5-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
|
|
98
|
-
"gpt-
|
|
99
|
-
"gpt-
|
|
100
|
-
o1: { inputPer1M: 15, outputPer1M: 60, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
|
|
333
|
+
"gpt-5-mini": { inputPer1M: 0.3, outputPer1M: 1.2, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
|
|
334
|
+
"gpt-5.2": { inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
|
|
101
335
|
"o1-mini": { inputPer1M: 3, outputPer1M: 12, cacheReadPer1M: 1.5, cacheWritePer1M: 0 },
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
"
|
|
336
|
+
"grok-3": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
337
|
+
"grok-3-mini": { inputPer1M: 0.3, outputPer1M: 0.5, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
338
|
+
"qwen3.6-plus": { inputPer1M: 0.8, outputPer1M: 2, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
339
|
+
"minimax-m2.7": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
340
|
+
"minimax-m2.7-highspeed": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
341
|
+
"minimax-m1": { inputPer1M: 0.2, outputPer1M: 1.1, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
342
|
+
"glm-5.1": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
343
|
+
"glm-5": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
344
|
+
"kimi-k2": { inputPer1M: 0.6, outputPer1M: 0.6, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
345
|
+
o3: { inputPer1M: 10, outputPer1M: 40, cacheReadPer1M: 2.5, cacheWritePer1M: 0 }
|
|
346
|
+
};
|
|
347
|
+
ADDITIONAL_LEGACY_DEFAULT_PRICING = {
|
|
348
|
+
"gemini-2.5-pro": [
|
|
349
|
+
{ inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0, cacheWritePer1M: 0 }
|
|
350
|
+
],
|
|
351
|
+
"qwen3.6-plus": [
|
|
352
|
+
{ inputPer1M: 0.325, outputPer1M: 1.95, cacheReadPer1M: 0, cacheWritePer1M: 0.40625 },
|
|
353
|
+
{ inputPer1M: 0.325, outputPer1M: 1.95, cacheReadPer1M: 0.05, cacheWritePer1M: 0.40625 }
|
|
354
|
+
],
|
|
355
|
+
"qwen3.6-flash": [
|
|
356
|
+
{ inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0, cacheWritePer1M: 0.3125 }
|
|
357
|
+
],
|
|
358
|
+
"qwen3.6-max-preview": [
|
|
359
|
+
{ inputPer1M: 1.04, outputPer1M: 6.24, cacheReadPer1M: 0, cacheWritePer1M: 1.3 },
|
|
360
|
+
{ inputPer1M: 1.04, outputPer1M: 6.24, cacheReadPer1M: 0.13, cacheWritePer1M: 1.3 }
|
|
361
|
+
],
|
|
362
|
+
"qwen/qwen3.6-plus": [
|
|
363
|
+
{ inputPer1M: 0.325, outputPer1M: 1.95, cacheReadPer1M: 0, cacheWritePer1M: 0.40625 },
|
|
364
|
+
{ inputPer1M: 0.325, outputPer1M: 1.95, cacheReadPer1M: 0.05, cacheWritePer1M: 0.40625 }
|
|
365
|
+
],
|
|
366
|
+
"qwen/qwen3.6-flash": [
|
|
367
|
+
{ inputPer1M: 0.25, outputPer1M: 1.5, cacheReadPer1M: 0, cacheWritePer1M: 0.3125 }
|
|
368
|
+
],
|
|
369
|
+
"qwen/qwen3.6-max-preview": [
|
|
370
|
+
{ inputPer1M: 1.04, outputPer1M: 6.24, cacheReadPer1M: 0, cacheWritePer1M: 1.3 },
|
|
371
|
+
{ inputPer1M: 1.04, outputPer1M: 6.24, cacheReadPer1M: 0.13, cacheWritePer1M: 1.3 }
|
|
372
|
+
]
|
|
373
|
+
};
|
|
374
|
+
REMOVED_DEFAULT_PRICING = {
|
|
375
|
+
"claude-3-5-sonnet": [
|
|
376
|
+
{ inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 6 },
|
|
377
|
+
{ inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 0 }
|
|
378
|
+
],
|
|
379
|
+
"claude-3-sonnet": [
|
|
380
|
+
{ inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 6 },
|
|
381
|
+
{ inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75, cacheWrite1hPer1M: 0 }
|
|
382
|
+
],
|
|
383
|
+
"gemini-3.1-pro": [
|
|
384
|
+
{ inputPer1M: 2, outputPer1M: 12, cacheReadPer1M: 0.2, cacheWritePer1M: 0 },
|
|
385
|
+
{ inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.31, cacheWritePer1M: 0 }
|
|
386
|
+
],
|
|
387
|
+
"gemini-1.5-pro": [
|
|
388
|
+
{ inputPer1M: 1.25, outputPer1M: 5, cacheReadPer1M: 0, cacheWritePer1M: 0 }
|
|
389
|
+
],
|
|
390
|
+
"gemini-1.5-flash": [
|
|
391
|
+
{ inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 }
|
|
392
|
+
],
|
|
393
|
+
"gpt-5.3-chat": [
|
|
394
|
+
{ inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.175, cacheWritePer1M: 0 },
|
|
395
|
+
{ inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 }
|
|
396
|
+
],
|
|
397
|
+
"qwen3.6": [
|
|
398
|
+
{ inputPer1M: 0.3, outputPer1M: 0.6, cacheReadPer1M: 0, cacheWritePer1M: 0 }
|
|
399
|
+
]
|
|
400
|
+
};
|
|
401
|
+
FREE_PRICING = {
|
|
402
|
+
inputPer1M: 0,
|
|
403
|
+
outputPer1M: 0,
|
|
404
|
+
cacheReadPer1M: 0,
|
|
405
|
+
cacheWritePer1M: 0,
|
|
406
|
+
cacheWrite1hPer1M: 0,
|
|
407
|
+
cacheStoragePer1MHour: 0
|
|
408
|
+
};
|
|
409
|
+
GEMINI_PROMPT_TIERS = {
|
|
410
|
+
"gemini-3.1-pro-preview": {
|
|
411
|
+
threshold: 200000,
|
|
412
|
+
inputPer1M: 4,
|
|
413
|
+
outputPer1M: 18,
|
|
414
|
+
cacheReadPer1M: 0.4
|
|
415
|
+
},
|
|
416
|
+
"gemini-2.5-pro": {
|
|
417
|
+
threshold: 200000,
|
|
418
|
+
inputPer1M: 2.5,
|
|
419
|
+
outputPer1M: 15,
|
|
420
|
+
cacheReadPer1M: 0.25
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
OPENAI_PROMPT_TIERS = {
|
|
424
|
+
"gpt-5.5": {
|
|
425
|
+
threshold: 272000,
|
|
426
|
+
inputMultiplier: 2,
|
|
427
|
+
outputMultiplier: 1.5,
|
|
428
|
+
cacheReadMultiplier: 2
|
|
429
|
+
},
|
|
430
|
+
"gpt-5.4-pro": {
|
|
431
|
+
threshold: 272000,
|
|
432
|
+
inputMultiplier: 2,
|
|
433
|
+
outputMultiplier: 1.5,
|
|
434
|
+
cacheReadMultiplier: 2
|
|
435
|
+
},
|
|
436
|
+
"gpt-5.4": {
|
|
437
|
+
threshold: 272000,
|
|
438
|
+
inputMultiplier: 2,
|
|
439
|
+
outputMultiplier: 1.5,
|
|
440
|
+
cacheReadMultiplier: 2
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
QWEN_PROMPT_TIERS = {
|
|
444
|
+
"qwen3.6-plus": {
|
|
445
|
+
threshold: 256000,
|
|
446
|
+
inputPer1M: 1.3,
|
|
447
|
+
outputPer1M: 3.9,
|
|
448
|
+
cacheReadPer1M: 0.13,
|
|
449
|
+
cacheWritePer1M: 1.625
|
|
450
|
+
},
|
|
451
|
+
"qwen3.6-flash": {
|
|
452
|
+
threshold: 256000,
|
|
453
|
+
inputPer1M: 1,
|
|
454
|
+
outputPer1M: 4,
|
|
455
|
+
cacheReadPer1M: 0.1,
|
|
456
|
+
cacheWritePer1M: 1.25
|
|
457
|
+
},
|
|
458
|
+
"qwen3.6-max-preview": {
|
|
459
|
+
threshold: 128000,
|
|
460
|
+
inputPer1M: 1.6,
|
|
461
|
+
outputPer1M: 9.6,
|
|
462
|
+
cacheReadPer1M: 0.16,
|
|
463
|
+
cacheWritePer1M: 2
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
MINIMAX_PROMPT_TIERS = {
|
|
467
|
+
"minimax/minimax-m1": {
|
|
468
|
+
threshold: Number.POSITIVE_INFINITY
|
|
469
|
+
},
|
|
470
|
+
"minimax-m1": {
|
|
471
|
+
threshold: 200000,
|
|
472
|
+
inputPer1M: 1.3
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
XAI_PROMPT_TIERS = {
|
|
476
|
+
"grok-4.3": {
|
|
477
|
+
threshold: 200000,
|
|
478
|
+
inputPer1M: 2.5,
|
|
479
|
+
outputPer1M: 5,
|
|
480
|
+
cacheReadPer1M: 0.4
|
|
481
|
+
},
|
|
482
|
+
"grok-latest": {
|
|
483
|
+
threshold: 200000,
|
|
484
|
+
inputPer1M: 2.5,
|
|
485
|
+
outputPer1M: 5,
|
|
486
|
+
cacheReadPer1M: 0.4
|
|
487
|
+
},
|
|
488
|
+
"grok-4.20": {
|
|
489
|
+
threshold: 200000,
|
|
490
|
+
inputPer1M: 2.5,
|
|
491
|
+
outputPer1M: 5,
|
|
492
|
+
cacheReadPer1M: 0.4
|
|
493
|
+
},
|
|
494
|
+
"grok-4-1-fast": {
|
|
495
|
+
threshold: 128000,
|
|
496
|
+
inputPer1M: 0.4,
|
|
497
|
+
outputPer1M: 1,
|
|
498
|
+
cacheReadPer1M: 0
|
|
499
|
+
},
|
|
500
|
+
"grok-4-fast": {
|
|
501
|
+
threshold: 128000,
|
|
502
|
+
inputPer1M: 0.4,
|
|
503
|
+
outputPer1M: 1,
|
|
504
|
+
cacheReadPer1M: 0
|
|
505
|
+
},
|
|
506
|
+
"grok-4": {
|
|
507
|
+
threshold: 128000,
|
|
508
|
+
inputPer1M: 6,
|
|
509
|
+
outputPer1M: 30,
|
|
510
|
+
cacheReadPer1M: 0
|
|
511
|
+
}
|
|
105
512
|
};
|
|
106
513
|
});
|
|
107
514
|
|
|
108
515
|
// src/db/database.ts
|
|
109
516
|
import { SqliteAdapter as Database } from "@hasna/cloud";
|
|
110
517
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
518
|
+
import { hostname } from "os";
|
|
111
519
|
import { homedir } from "os";
|
|
112
520
|
import { join } from "path";
|
|
521
|
+
function getMachineId() {
|
|
522
|
+
if (process.env["ECONOMY_MACHINE_ID"])
|
|
523
|
+
return process.env["ECONOMY_MACHINE_ID"];
|
|
524
|
+
const h = hostname().toLowerCase();
|
|
525
|
+
if (h.startsWith("spark") || h.startsWith("apple"))
|
|
526
|
+
return h.split(".")[0];
|
|
527
|
+
return h.split(".")[0];
|
|
528
|
+
}
|
|
113
529
|
function getDataDir() {
|
|
114
530
|
const home = process.env["HOME"] || process.env["USERPROFILE"] || homedir();
|
|
115
531
|
const newDir = join(home, ".hasna", "economy");
|
|
@@ -142,6 +558,7 @@ function openDatabase(dbPath, skipSeed = false) {
|
|
|
142
558
|
}
|
|
143
559
|
const db = new Database(path);
|
|
144
560
|
db.exec("PRAGMA journal_mode = WAL");
|
|
561
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
145
562
|
db.exec("PRAGMA foreign_keys = ON");
|
|
146
563
|
initSchema(db);
|
|
147
564
|
if (!skipSeed) {
|
|
@@ -160,10 +577,13 @@ function initSchema(db) {
|
|
|
160
577
|
output_tokens INTEGER DEFAULT 0,
|
|
161
578
|
cache_read_tokens INTEGER DEFAULT 0,
|
|
162
579
|
cache_create_tokens INTEGER DEFAULT 0,
|
|
580
|
+
cache_create_5m_tokens INTEGER DEFAULT 0,
|
|
581
|
+
cache_create_1h_tokens INTEGER DEFAULT 0,
|
|
163
582
|
cost_usd REAL NOT NULL DEFAULT 0,
|
|
164
583
|
duration_ms INTEGER DEFAULT 0,
|
|
165
584
|
timestamp TEXT NOT NULL,
|
|
166
|
-
source_request_id TEXT
|
|
585
|
+
source_request_id TEXT,
|
|
586
|
+
machine_id TEXT DEFAULT ''
|
|
167
587
|
);
|
|
168
588
|
|
|
169
589
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
@@ -175,7 +595,8 @@ function initSchema(db) {
|
|
|
175
595
|
ended_at TEXT,
|
|
176
596
|
total_cost_usd REAL DEFAULT 0,
|
|
177
597
|
total_tokens INTEGER DEFAULT 0,
|
|
178
|
-
request_count INTEGER DEFAULT 0
|
|
598
|
+
request_count INTEGER DEFAULT 0,
|
|
599
|
+
machine_id TEXT DEFAULT ''
|
|
179
600
|
);
|
|
180
601
|
|
|
181
602
|
CREATE TABLE IF NOT EXISTS projects (
|
|
@@ -228,6 +649,8 @@ function initSchema(db) {
|
|
|
228
649
|
output_per_1m REAL NOT NULL DEFAULT 0,
|
|
229
650
|
cache_read_per_1m REAL NOT NULL DEFAULT 0,
|
|
230
651
|
cache_write_per_1m REAL NOT NULL DEFAULT 0,
|
|
652
|
+
cache_write_1h_per_1m REAL NOT NULL DEFAULT 0,
|
|
653
|
+
cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0,
|
|
231
654
|
updated_at TEXT NOT NULL
|
|
232
655
|
);
|
|
233
656
|
|
|
@@ -240,6 +663,115 @@ function initSchema(db) {
|
|
|
240
663
|
machine_id TEXT,
|
|
241
664
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
242
665
|
);
|
|
666
|
+
|
|
667
|
+
CREATE TABLE IF NOT EXISTS billing_daily (
|
|
668
|
+
date TEXT NOT NULL,
|
|
669
|
+
provider TEXT NOT NULL,
|
|
670
|
+
description TEXT DEFAULT '',
|
|
671
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
672
|
+
updated_at TEXT NOT NULL,
|
|
673
|
+
PRIMARY KEY (date, provider, description)
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
CREATE INDEX IF NOT EXISTS idx_billing_date ON billing_daily(date);
|
|
677
|
+
CREATE INDEX IF NOT EXISTS idx_billing_provider ON billing_daily(provider);
|
|
678
|
+
|
|
679
|
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
680
|
+
id TEXT PRIMARY KEY,
|
|
681
|
+
agent TEXT,
|
|
682
|
+
provider TEXT NOT NULL,
|
|
683
|
+
plan TEXT NOT NULL,
|
|
684
|
+
monthly_fee_usd REAL NOT NULL DEFAULT 0,
|
|
685
|
+
included_usage_usd REAL NOT NULL DEFAULT 0,
|
|
686
|
+
billing_cycle_start TEXT,
|
|
687
|
+
reset_policy TEXT DEFAULT 'monthly',
|
|
688
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
689
|
+
created_at TEXT NOT NULL,
|
|
690
|
+
updated_at TEXT NOT NULL
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
CREATE TABLE IF NOT EXISTS usage_snapshots (
|
|
694
|
+
id TEXT PRIMARY KEY,
|
|
695
|
+
agent TEXT NOT NULL,
|
|
696
|
+
date TEXT NOT NULL,
|
|
697
|
+
metric TEXT NOT NULL,
|
|
698
|
+
value REAL NOT NULL DEFAULT 0,
|
|
699
|
+
unit TEXT DEFAULT '',
|
|
700
|
+
machine_id TEXT DEFAULT '',
|
|
701
|
+
updated_at TEXT NOT NULL
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
CREATE TABLE IF NOT EXISTS savings_daily (
|
|
705
|
+
date TEXT NOT NULL,
|
|
706
|
+
agent TEXT DEFAULT '',
|
|
707
|
+
api_equivalent_usd REAL NOT NULL DEFAULT 0,
|
|
708
|
+
subscription_fee_usd REAL NOT NULL DEFAULT 0,
|
|
709
|
+
included_consumed_usd REAL NOT NULL DEFAULT 0,
|
|
710
|
+
on_demand_usd REAL NOT NULL DEFAULT 0,
|
|
711
|
+
saved_usd REAL NOT NULL DEFAULT 0,
|
|
712
|
+
updated_at TEXT NOT NULL,
|
|
713
|
+
PRIMARY KEY (date, agent)
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
CREATE TABLE IF NOT EXISTS machines (
|
|
717
|
+
machine_id TEXT PRIMARY KEY,
|
|
718
|
+
hostname TEXT NOT NULL,
|
|
719
|
+
last_seen_at TEXT,
|
|
720
|
+
last_push_at TEXT,
|
|
721
|
+
last_pull_at TEXT,
|
|
722
|
+
economy_version TEXT,
|
|
723
|
+
updated_at TEXT NOT NULL
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date);
|
|
727
|
+
CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date);
|
|
728
|
+
`);
|
|
729
|
+
const cols = db.prepare(`PRAGMA table_info(requests)`).all();
|
|
730
|
+
if (!cols.some((c) => c.name === "machine_id")) {
|
|
731
|
+
db.exec(`ALTER TABLE requests ADD COLUMN machine_id TEXT DEFAULT ''`);
|
|
732
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN machine_id TEXT DEFAULT ''`);
|
|
733
|
+
}
|
|
734
|
+
if (!cols.some((c) => c.name === "cache_create_5m_tokens")) {
|
|
735
|
+
db.exec(`ALTER TABLE requests ADD COLUMN cache_create_5m_tokens INTEGER DEFAULT 0`);
|
|
736
|
+
db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
|
|
737
|
+
}
|
|
738
|
+
if (!cols.some((c) => c.name === "cache_create_1h_tokens")) {
|
|
739
|
+
db.exec(`ALTER TABLE requests ADD COLUMN cache_create_1h_tokens INTEGER DEFAULT 0`);
|
|
740
|
+
}
|
|
741
|
+
if (!cols.some((c) => c.name === "cost_basis")) {
|
|
742
|
+
db.exec(`ALTER TABLE requests ADD COLUMN cost_basis TEXT DEFAULT 'estimated'`);
|
|
743
|
+
}
|
|
744
|
+
if (!cols.some((c) => c.name === "attribution_tag")) {
|
|
745
|
+
db.exec(`ALTER TABLE requests ADD COLUMN attribution_tag TEXT DEFAULT ''`);
|
|
746
|
+
}
|
|
747
|
+
if (!cols.some((c) => c.name === "updated_at")) {
|
|
748
|
+
db.exec(`ALTER TABLE requests ADD COLUMN updated_at TEXT DEFAULT ''`);
|
|
749
|
+
db.exec(`UPDATE requests SET updated_at = timestamp WHERE updated_at = '' OR updated_at IS NULL`);
|
|
750
|
+
}
|
|
751
|
+
if (!cols.some((c) => c.name === "synced_at")) {
|
|
752
|
+
db.exec(`ALTER TABLE requests ADD COLUMN synced_at TEXT DEFAULT ''`);
|
|
753
|
+
}
|
|
754
|
+
const sessionCols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
755
|
+
if (!sessionCols.some((c) => c.name === "attribution_tag")) {
|
|
756
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN attribution_tag TEXT DEFAULT ''`);
|
|
757
|
+
}
|
|
758
|
+
if (!sessionCols.some((c) => c.name === "updated_at")) {
|
|
759
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN updated_at TEXT DEFAULT ''`);
|
|
760
|
+
db.exec(`UPDATE sessions SET updated_at = started_at WHERE updated_at = '' OR updated_at IS NULL`);
|
|
761
|
+
}
|
|
762
|
+
if (!sessionCols.some((c) => c.name === "synced_at")) {
|
|
763
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN synced_at TEXT DEFAULT ''`);
|
|
764
|
+
}
|
|
765
|
+
const pricingCols = db.prepare(`PRAGMA table_info(model_pricing)`).all();
|
|
766
|
+
if (!pricingCols.some((c) => c.name === "cache_write_1h_per_1m")) {
|
|
767
|
+
db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`);
|
|
768
|
+
}
|
|
769
|
+
if (!pricingCols.some((c) => c.name === "cache_storage_per_1m_hour")) {
|
|
770
|
+
db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`);
|
|
771
|
+
}
|
|
772
|
+
db.exec(`
|
|
773
|
+
CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
|
|
774
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
|
|
243
775
|
`);
|
|
244
776
|
}
|
|
245
777
|
function periodWhere(period) {
|
|
@@ -249,11 +781,11 @@ function periodWhere(period) {
|
|
|
249
781
|
case "yesterday":
|
|
250
782
|
return `DATE(timestamp) = DATE('now', '-1 day')`;
|
|
251
783
|
case "week":
|
|
252
|
-
return `timestamp >= DATE('now', '-7 days')`;
|
|
784
|
+
return `timestamp >= DATE('now', 'weekday 0', '-7 days')`;
|
|
253
785
|
case "month":
|
|
254
|
-
return `timestamp >= DATE('now', '
|
|
786
|
+
return `timestamp >= DATE('now', 'start of month')`;
|
|
255
787
|
case "year":
|
|
256
|
-
return `timestamp >= DATE('now', '
|
|
788
|
+
return `timestamp >= DATE('now', 'start of year')`;
|
|
257
789
|
case "all":
|
|
258
790
|
return "1=1";
|
|
259
791
|
}
|
|
@@ -265,31 +797,34 @@ function sessionPeriodWhere(period) {
|
|
|
265
797
|
case "yesterday":
|
|
266
798
|
return `DATE(started_at) = DATE('now', '-1 day')`;
|
|
267
799
|
case "week":
|
|
268
|
-
return `started_at >= DATE('now', '-7 days')`;
|
|
800
|
+
return `started_at >= DATE('now', 'weekday 0', '-7 days')`;
|
|
269
801
|
case "month":
|
|
270
|
-
return `started_at >= DATE('now', '
|
|
802
|
+
return `started_at >= DATE('now', 'start of month')`;
|
|
271
803
|
case "year":
|
|
272
|
-
return `started_at >= DATE('now', '
|
|
804
|
+
return `started_at >= DATE('now', 'start of year')`;
|
|
273
805
|
case "all":
|
|
274
806
|
return "1=1";
|
|
275
807
|
}
|
|
276
808
|
}
|
|
277
809
|
function upsertRequest(db, req) {
|
|
810
|
+
const now = req.updated_at ?? new Date().toISOString();
|
|
278
811
|
db.prepare(`
|
|
279
812
|
INSERT OR REPLACE INTO requests
|
|
280
813
|
(id, agent, session_id, model, input_tokens, output_tokens,
|
|
281
|
-
cache_read_tokens, cache_create_tokens,
|
|
282
|
-
timestamp,
|
|
283
|
-
|
|
284
|
-
|
|
814
|
+
cache_read_tokens, cache_create_tokens, cache_create_5m_tokens,
|
|
815
|
+
cache_create_1h_tokens, cost_usd, cost_basis, duration_ms, timestamp,
|
|
816
|
+
source_request_id, machine_id, attribution_tag, updated_at, synced_at)
|
|
817
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
818
|
+
`).run(req.id, req.agent, req.session_id, req.model, req.input_tokens, req.output_tokens, req.cache_read_tokens, req.cache_create_tokens, req.cache_create_5m_tokens ?? req.cache_create_tokens, req.cache_create_1h_tokens ?? 0, req.cost_usd, req.cost_basis ?? "estimated", req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "", req.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", now, req.synced_at ?? "");
|
|
285
819
|
}
|
|
286
820
|
function upsertSession(db, session) {
|
|
821
|
+
const now = session.updated_at ?? new Date().toISOString();
|
|
287
822
|
db.prepare(`
|
|
288
823
|
INSERT OR REPLACE INTO sessions
|
|
289
824
|
(id, agent, project_path, project_name, started_at, ended_at,
|
|
290
|
-
total_cost_usd, total_tokens, request_count)
|
|
291
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
292
|
-
`).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count);
|
|
825
|
+
total_cost_usd, total_tokens, request_count, machine_id, attribution_tag, updated_at, synced_at)
|
|
826
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
827
|
+
`).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count, session.machine_id ?? "", session.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", now, session.synced_at ?? "");
|
|
293
828
|
}
|
|
294
829
|
function rollupSession(db, sessionId) {
|
|
295
830
|
db.prepare(`
|
|
@@ -319,6 +854,10 @@ function querySessions(db, filter = {}) {
|
|
|
319
854
|
conditions.push("started_at >= ?");
|
|
320
855
|
params.push(filter.since);
|
|
321
856
|
}
|
|
857
|
+
if (filter.machine) {
|
|
858
|
+
conditions.push("machine_id = ?");
|
|
859
|
+
params.push(filter.machine);
|
|
860
|
+
}
|
|
322
861
|
if (filter.search) {
|
|
323
862
|
const q = `%${filter.search}%`;
|
|
324
863
|
conditions.push("(project_name LIKE ? OR agent LIKE ? OR id LIKE ?)");
|
|
@@ -337,24 +876,25 @@ function queryTopSessions(db, n = 10, agent) {
|
|
|
337
876
|
}
|
|
338
877
|
return db.prepare(`SELECT * FROM sessions ORDER BY total_cost_usd DESC LIMIT ?`).all(n);
|
|
339
878
|
}
|
|
340
|
-
function querySummary(db, period) {
|
|
879
|
+
function querySummary(db, period, machine, allMachines = false) {
|
|
341
880
|
const rWhere = periodWhere(period);
|
|
342
881
|
const sWhere = sessionPeriodWhere(period);
|
|
882
|
+
const machineClause = !allMachines && machine ? ` AND machine_id = '${machine.replace(/'/g, "''")}'` : "";
|
|
343
883
|
const r = db.prepare(`
|
|
344
884
|
SELECT COALESCE(SUM(cost_usd), 0) as total_usd,
|
|
345
885
|
COUNT(*) as requests,
|
|
346
886
|
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as tokens
|
|
347
|
-
FROM requests WHERE ${rWhere}
|
|
887
|
+
FROM requests WHERE ${rWhere}${machineClause}
|
|
348
888
|
`).get();
|
|
349
889
|
const codexTotals = db.prepare(`
|
|
350
890
|
SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
|
|
351
891
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
|
352
892
|
COUNT(*) as sessions
|
|
353
893
|
FROM sessions
|
|
354
|
-
WHERE ${sWhere}
|
|
894
|
+
WHERE ${sWhere}${machineClause}
|
|
355
895
|
AND id NOT IN (SELECT DISTINCT session_id FROM requests)
|
|
356
896
|
`).get();
|
|
357
|
-
const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}`).get();
|
|
897
|
+
const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}${machineClause}`).get();
|
|
358
898
|
return {
|
|
359
899
|
total_usd: r.total_usd + codexTotals.cost_usd,
|
|
360
900
|
requests: r.requests,
|
|
@@ -374,23 +914,66 @@ function queryModelBreakdown(db) {
|
|
|
374
914
|
FROM requests GROUP BY model, agent ORDER BY cost_usd DESC
|
|
375
915
|
`).all();
|
|
376
916
|
}
|
|
917
|
+
function labelForPath(projectPath, projectName) {
|
|
918
|
+
if (projectName && projectName.trim() !== "")
|
|
919
|
+
return projectName;
|
|
920
|
+
if (!projectPath)
|
|
921
|
+
return "";
|
|
922
|
+
const segments = projectPath.split("/").filter(Boolean);
|
|
923
|
+
const projectPrefix = /^(open|skill|hook|service|connect|platform|agent|tool|iapp|project|scaffold|capp)-/;
|
|
924
|
+
for (const seg of segments) {
|
|
925
|
+
if (projectPrefix.test(seg))
|
|
926
|
+
return seg;
|
|
927
|
+
}
|
|
928
|
+
const generic = new Set(["web", "app", "apps", "packages", "src", "lib", "server", "client", "api", "frontend", "backend"]);
|
|
929
|
+
for (let i = segments.length - 1;i >= 0; i--) {
|
|
930
|
+
if (!generic.has(segments[i].toLowerCase()))
|
|
931
|
+
return segments[i];
|
|
932
|
+
}
|
|
933
|
+
return segments[segments.length - 1] ?? projectPath;
|
|
934
|
+
}
|
|
377
935
|
function queryProjectBreakdown(db) {
|
|
378
|
-
|
|
379
|
-
SELECT
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
COUNT(DISTINCT s.id) as sessions,
|
|
383
|
-
COUNT(r.id) as requests,
|
|
384
|
-
COALESCE(SUM(r.cost_usd), COALESCE(SUM(s.total_cost_usd), 0)) as cost_usd,
|
|
385
|
-
COALESCE(SUM(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens), 0) as total_tokens,
|
|
386
|
-
MAX(s.started_at) as last_active
|
|
387
|
-
FROM sessions s
|
|
388
|
-
LEFT JOIN projects p ON p.path = s.project_path OR p.name = s.project_name
|
|
389
|
-
LEFT JOIN requests r ON r.session_id = s.id
|
|
390
|
-
WHERE s.project_path != '' OR s.project_name != ''
|
|
391
|
-
GROUP BY s.project_path
|
|
392
|
-
ORDER BY cost_usd DESC
|
|
936
|
+
const sessions = db.prepare(`
|
|
937
|
+
SELECT id, project_path, project_name, total_cost_usd, started_at
|
|
938
|
+
FROM sessions
|
|
939
|
+
WHERE project_path != '' OR project_name != ''
|
|
393
940
|
`).all();
|
|
941
|
+
const groups = new Map;
|
|
942
|
+
for (const s of sessions) {
|
|
943
|
+
const label = labelForPath(s.project_path, s.project_name);
|
|
944
|
+
if (!label)
|
|
945
|
+
continue;
|
|
946
|
+
const g = groups.get(label) ?? { sessionIds: [], samplePath: s.project_path, totalCost: 0, lastActive: "" };
|
|
947
|
+
g.sessionIds.push(s.id);
|
|
948
|
+
g.totalCost += s.total_cost_usd || 0;
|
|
949
|
+
if (!g.lastActive || s.started_at > g.lastActive)
|
|
950
|
+
g.lastActive = s.started_at;
|
|
951
|
+
if (!g.samplePath)
|
|
952
|
+
g.samplePath = s.project_path;
|
|
953
|
+
groups.set(label, g);
|
|
954
|
+
}
|
|
955
|
+
const result = [];
|
|
956
|
+
for (const [label, g] of groups.entries()) {
|
|
957
|
+
const placeholders = g.sessionIds.map(() => "?").join(",");
|
|
958
|
+
const reqStats = placeholders.length ? db.prepare(`
|
|
959
|
+
SELECT
|
|
960
|
+
COUNT(*) as requests,
|
|
961
|
+
COALESCE(SUM(cost_usd), 0) as cost_usd,
|
|
962
|
+
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens
|
|
963
|
+
FROM requests WHERE session_id IN (${placeholders})
|
|
964
|
+
`).get(...g.sessionIds) : { requests: 0, cost_usd: 0, total_tokens: 0 };
|
|
965
|
+
result.push({
|
|
966
|
+
project_path: g.samplePath,
|
|
967
|
+
project_name: label,
|
|
968
|
+
sessions: g.sessionIds.length,
|
|
969
|
+
requests: reqStats.requests,
|
|
970
|
+
total_tokens: reqStats.total_tokens,
|
|
971
|
+
cost_usd: reqStats.cost_usd > 0 ? reqStats.cost_usd : g.totalCost,
|
|
972
|
+
last_active: g.lastActive
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
result.sort((a, b) => b.cost_usd - a.cost_usd);
|
|
976
|
+
return result;
|
|
394
977
|
}
|
|
395
978
|
function queryDailyBreakdown(db, days = 30) {
|
|
396
979
|
return db.prepare(`
|
|
@@ -499,12 +1082,46 @@ function getIngestState(db, source, key) {
|
|
|
499
1082
|
function setIngestState(db, source, key, value) {
|
|
500
1083
|
db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES (?, ?, ?)`).run(source, key, value);
|
|
501
1084
|
}
|
|
1085
|
+
function upsertBillingDaily(db, row) {
|
|
1086
|
+
db.prepare(`
|
|
1087
|
+
INSERT OR REPLACE INTO billing_daily (date, provider, description, cost_usd, updated_at)
|
|
1088
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1089
|
+
`).run(row.date, row.provider, row.description, row.cost_usd, row.updated_at);
|
|
1090
|
+
}
|
|
1091
|
+
function clearBillingRange(db, provider, fromDate, toDate) {
|
|
1092
|
+
db.prepare(`DELETE FROM billing_daily WHERE provider = ? AND date >= ? AND date <= ?`).run(provider, fromDate, toDate);
|
|
1093
|
+
}
|
|
1094
|
+
function queryBillingSummary(db, period) {
|
|
1095
|
+
const where = period === "today" ? `date = DATE('now')` : period === "yesterday" ? `date = DATE('now', '-1 day')` : period === "week" ? `date >= DATE('now', 'weekday 0', '-7 days')` : period === "month" ? `date >= DATE('now', 'start of month')` : period === "year" ? `date >= DATE('now', 'start of year')` : "1=1";
|
|
1096
|
+
const rows = db.prepare(`SELECT provider, SUM(cost_usd) as cost FROM billing_daily WHERE ${where} GROUP BY provider`).all();
|
|
1097
|
+
const by_provider = {};
|
|
1098
|
+
let total = 0;
|
|
1099
|
+
for (const r of rows) {
|
|
1100
|
+
by_provider[r.provider] = r.cost;
|
|
1101
|
+
total += r.cost;
|
|
1102
|
+
}
|
|
1103
|
+
return { total_usd: total, by_provider };
|
|
1104
|
+
}
|
|
1105
|
+
function listMachines(db) {
|
|
1106
|
+
return db.prepare(`
|
|
1107
|
+
SELECT
|
|
1108
|
+
s.machine_id,
|
|
1109
|
+
COUNT(DISTINCT s.id) as sessions,
|
|
1110
|
+
COALESCE((SELECT COUNT(*) FROM requests r WHERE r.machine_id = s.machine_id), 0) as requests,
|
|
1111
|
+
COALESCE(SUM(s.total_cost_usd), 0) as total_cost_usd,
|
|
1112
|
+
MAX(s.started_at) as last_active
|
|
1113
|
+
FROM sessions s
|
|
1114
|
+
WHERE s.machine_id != ''
|
|
1115
|
+
GROUP BY s.machine_id
|
|
1116
|
+
ORDER BY total_cost_usd DESC
|
|
1117
|
+
`).all();
|
|
1118
|
+
}
|
|
502
1119
|
function upsertModelPricing(db, p) {
|
|
503
1120
|
db.prepare(`
|
|
504
1121
|
INSERT OR REPLACE INTO model_pricing
|
|
505
|
-
(model, input_per_1m, output_per_1m, cache_read_per_1m, cache_write_per_1m, updated_at)
|
|
506
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
507
|
-
`).run(p.model, p.input_per_1m, p.output_per_1m, p.cache_read_per_1m, p.cache_write_per_1m, p.updated_at);
|
|
1122
|
+
(model, input_per_1m, output_per_1m, cache_read_per_1m, cache_write_per_1m, cache_write_1h_per_1m, cache_storage_per_1m_hour, updated_at)
|
|
1123
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1124
|
+
`).run(p.model, p.input_per_1m, p.output_per_1m, p.cache_read_per_1m, p.cache_write_per_1m, p.cache_write_1h_per_1m ?? 0, p.cache_storage_per_1m_hour ?? 0, p.updated_at);
|
|
508
1125
|
}
|
|
509
1126
|
function getModelPricing(db, model) {
|
|
510
1127
|
return db.prepare(`SELECT * FROM model_pricing WHERE model = ?`).get(model);
|
|
@@ -516,25 +1133,655 @@ function deleteModelPricing(db, model) {
|
|
|
516
1133
|
db.prepare(`DELETE FROM model_pricing WHERE model = ?`).run(model);
|
|
517
1134
|
}
|
|
518
1135
|
function seedModelPricing(db, defaults) {
|
|
519
|
-
const existing = db.prepare(`SELECT
|
|
520
|
-
if (existing.count > 0)
|
|
521
|
-
return;
|
|
1136
|
+
const existing = new Set(db.prepare(`SELECT model FROM model_pricing`).all().map((r) => r.model));
|
|
522
1137
|
const now = new Date().toISOString();
|
|
523
1138
|
for (const [model, p] of Object.entries(defaults)) {
|
|
1139
|
+
if (existing.has(model))
|
|
1140
|
+
continue;
|
|
524
1141
|
upsertModelPricing(db, {
|
|
525
1142
|
model,
|
|
526
1143
|
input_per_1m: p.inputPer1M,
|
|
527
1144
|
output_per_1m: p.outputPer1M,
|
|
528
1145
|
cache_read_per_1m: p.cacheReadPer1M,
|
|
529
1146
|
cache_write_per_1m: p.cacheWritePer1M,
|
|
1147
|
+
cache_write_1h_per_1m: p.cacheWrite1hPer1M ?? 0,
|
|
1148
|
+
cache_storage_per_1m_hour: p.cacheStoragePer1MHour ?? 0,
|
|
530
1149
|
updated_at: now
|
|
531
1150
|
});
|
|
532
1151
|
}
|
|
533
1152
|
}
|
|
1153
|
+
function upsertSubscription(db, sub) {
|
|
1154
|
+
db.prepare(`
|
|
1155
|
+
INSERT OR REPLACE INTO subscriptions
|
|
1156
|
+
(id, agent, provider, plan, monthly_fee_usd, included_usage_usd, billing_cycle_start, reset_policy, active, created_at, updated_at)
|
|
1157
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1158
|
+
`).run(sub.id, sub.agent, sub.provider, sub.plan, sub.monthly_fee_usd, sub.included_usage_usd, sub.billing_cycle_start, sub.reset_policy, sub.active, sub.created_at, sub.updated_at);
|
|
1159
|
+
}
|
|
1160
|
+
function upsertUsageSnapshot(db, snap) {
|
|
1161
|
+
const now = snap.updated_at ?? new Date().toISOString();
|
|
1162
|
+
const id = snap.id ?? `${snap.agent}-${snap.date}-${snap.metric}-${snap.machine_id}`;
|
|
1163
|
+
db.prepare(`
|
|
1164
|
+
INSERT OR REPLACE INTO usage_snapshots (id, agent, date, metric, value, unit, machine_id, updated_at)
|
|
1165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1166
|
+
`).run(id, snap.agent, snap.date, snap.metric, snap.value, snap.unit, snap.machine_id, now);
|
|
1167
|
+
}
|
|
1168
|
+
function queryUsageSnapshots(db, opts = {}) {
|
|
1169
|
+
const conditions = [];
|
|
1170
|
+
const params = [];
|
|
1171
|
+
if (opts.agent) {
|
|
1172
|
+
conditions.push("agent = ?");
|
|
1173
|
+
params.push(opts.agent);
|
|
1174
|
+
}
|
|
1175
|
+
if (opts.date) {
|
|
1176
|
+
conditions.push("date = ?");
|
|
1177
|
+
params.push(opts.date);
|
|
1178
|
+
}
|
|
1179
|
+
if (opts.since) {
|
|
1180
|
+
conditions.push("date >= ?");
|
|
1181
|
+
params.push(opts.since);
|
|
1182
|
+
}
|
|
1183
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1184
|
+
return db.prepare(`SELECT * FROM usage_snapshots ${where} ORDER BY date DESC, agent, metric`).all(...params);
|
|
1185
|
+
}
|
|
1186
|
+
function listMachineRegistry(db) {
|
|
1187
|
+
return db.prepare(`SELECT * FROM machines ORDER BY last_seen_at DESC`).all();
|
|
1188
|
+
}
|
|
1189
|
+
function dedupeRequests(db) {
|
|
1190
|
+
const dupes = db.prepare(`
|
|
1191
|
+
SELECT source_request_id, agent, MIN(id) as keep_id, COUNT(*) as cnt
|
|
1192
|
+
FROM requests
|
|
1193
|
+
WHERE source_request_id != '' AND source_request_id IS NOT NULL
|
|
1194
|
+
GROUP BY source_request_id, agent
|
|
1195
|
+
HAVING cnt > 1
|
|
1196
|
+
`).all();
|
|
1197
|
+
let removed = 0;
|
|
1198
|
+
for (const row of dupes) {
|
|
1199
|
+
const result = db.prepare(`
|
|
1200
|
+
DELETE FROM requests WHERE source_request_id = ? AND agent = ? AND id != ?
|
|
1201
|
+
`).run(row.source_request_id, row.agent, row.keep_id);
|
|
1202
|
+
removed += result.changes;
|
|
1203
|
+
}
|
|
1204
|
+
return removed;
|
|
1205
|
+
}
|
|
534
1206
|
var init_database = () => {};
|
|
535
1207
|
|
|
1208
|
+
// src/db/pg-migrations.ts
|
|
1209
|
+
var exports_pg_migrations = {};
|
|
1210
|
+
__export(exports_pg_migrations, {
|
|
1211
|
+
PG_MIGRATIONS: () => PG_MIGRATIONS
|
|
1212
|
+
});
|
|
1213
|
+
var PG_MIGRATIONS;
|
|
1214
|
+
var init_pg_migrations = __esm(() => {
|
|
1215
|
+
PG_MIGRATIONS = [
|
|
1216
|
+
`CREATE TABLE IF NOT EXISTS requests (
|
|
1217
|
+
id TEXT PRIMARY KEY,
|
|
1218
|
+
agent TEXT NOT NULL,
|
|
1219
|
+
session_id TEXT NOT NULL,
|
|
1220
|
+
model TEXT NOT NULL,
|
|
1221
|
+
input_tokens INTEGER DEFAULT 0,
|
|
1222
|
+
output_tokens INTEGER DEFAULT 0,
|
|
1223
|
+
cache_read_tokens INTEGER DEFAULT 0,
|
|
1224
|
+
cache_create_tokens INTEGER DEFAULT 0,
|
|
1225
|
+
cache_create_5m_tokens INTEGER DEFAULT 0,
|
|
1226
|
+
cache_create_1h_tokens INTEGER DEFAULT 0,
|
|
1227
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
1228
|
+
duration_ms INTEGER DEFAULT 0,
|
|
1229
|
+
timestamp TEXT NOT NULL,
|
|
1230
|
+
source_request_id TEXT,
|
|
1231
|
+
machine_id TEXT DEFAULT ''
|
|
1232
|
+
)`,
|
|
1233
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
1234
|
+
id TEXT PRIMARY KEY,
|
|
1235
|
+
agent TEXT NOT NULL,
|
|
1236
|
+
project_path TEXT DEFAULT '',
|
|
1237
|
+
project_name TEXT DEFAULT '',
|
|
1238
|
+
started_at TEXT NOT NULL,
|
|
1239
|
+
ended_at TEXT,
|
|
1240
|
+
total_cost_usd REAL DEFAULT 0,
|
|
1241
|
+
total_tokens INTEGER DEFAULT 0,
|
|
1242
|
+
request_count INTEGER DEFAULT 0,
|
|
1243
|
+
machine_id TEXT DEFAULT ''
|
|
1244
|
+
)`,
|
|
1245
|
+
`CREATE TABLE IF NOT EXISTS projects (
|
|
1246
|
+
id TEXT PRIMARY KEY,
|
|
1247
|
+
path TEXT UNIQUE NOT NULL,
|
|
1248
|
+
name TEXT NOT NULL,
|
|
1249
|
+
description TEXT,
|
|
1250
|
+
tags TEXT DEFAULT '[]',
|
|
1251
|
+
created_at TEXT NOT NULL
|
|
1252
|
+
)`,
|
|
1253
|
+
`CREATE TABLE IF NOT EXISTS budgets (
|
|
1254
|
+
id TEXT PRIMARY KEY,
|
|
1255
|
+
project_path TEXT,
|
|
1256
|
+
agent TEXT,
|
|
1257
|
+
period TEXT NOT NULL,
|
|
1258
|
+
limit_usd REAL NOT NULL,
|
|
1259
|
+
alert_at_percent INTEGER DEFAULT 80,
|
|
1260
|
+
created_at TEXT NOT NULL,
|
|
1261
|
+
updated_at TEXT NOT NULL
|
|
1262
|
+
)`,
|
|
1263
|
+
`CREATE TABLE IF NOT EXISTS goals (
|
|
1264
|
+
id TEXT PRIMARY KEY,
|
|
1265
|
+
period TEXT NOT NULL,
|
|
1266
|
+
project_path TEXT,
|
|
1267
|
+
agent TEXT,
|
|
1268
|
+
limit_usd REAL NOT NULL,
|
|
1269
|
+
created_at TEXT NOT NULL,
|
|
1270
|
+
updated_at TEXT NOT NULL
|
|
1271
|
+
)`,
|
|
1272
|
+
`CREATE TABLE IF NOT EXISTS ingest_state (
|
|
1273
|
+
source TEXT NOT NULL,
|
|
1274
|
+
key TEXT NOT NULL,
|
|
1275
|
+
value TEXT NOT NULL,
|
|
1276
|
+
PRIMARY KEY (source, key)
|
|
1277
|
+
)`,
|
|
1278
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)`,
|
|
1279
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp)`,
|
|
1280
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent)`,
|
|
1281
|
+
`CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id)`,
|
|
1282
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`,
|
|
1283
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
|
|
1284
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
|
|
1285
|
+
`CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
|
|
1286
|
+
`CREATE TABLE IF NOT EXISTS model_pricing (
|
|
1287
|
+
model TEXT PRIMARY KEY,
|
|
1288
|
+
input_per_1m REAL NOT NULL DEFAULT 0,
|
|
1289
|
+
output_per_1m REAL NOT NULL DEFAULT 0,
|
|
1290
|
+
cache_read_per_1m REAL NOT NULL DEFAULT 0,
|
|
1291
|
+
cache_write_per_1m REAL NOT NULL DEFAULT 0,
|
|
1292
|
+
cache_write_1h_per_1m REAL NOT NULL DEFAULT 0,
|
|
1293
|
+
cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0,
|
|
1294
|
+
updated_at TEXT NOT NULL
|
|
1295
|
+
)`,
|
|
1296
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS cache_create_5m_tokens INTEGER DEFAULT 0`,
|
|
1297
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS cache_create_1h_tokens INTEGER DEFAULT 0`,
|
|
1298
|
+
`ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`,
|
|
1299
|
+
`ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`,
|
|
1300
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
1301
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
1302
|
+
message TEXT NOT NULL,
|
|
1303
|
+
email TEXT,
|
|
1304
|
+
category TEXT DEFAULT 'general',
|
|
1305
|
+
version TEXT,
|
|
1306
|
+
machine_id TEXT,
|
|
1307
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
1308
|
+
)`,
|
|
1309
|
+
`CREATE TABLE IF NOT EXISTS billing_daily (
|
|
1310
|
+
date TEXT NOT NULL,
|
|
1311
|
+
provider TEXT NOT NULL,
|
|
1312
|
+
description TEXT DEFAULT '',
|
|
1313
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
1314
|
+
updated_at TEXT NOT NULL,
|
|
1315
|
+
PRIMARY KEY (date, provider, description)
|
|
1316
|
+
)`,
|
|
1317
|
+
`CREATE INDEX IF NOT EXISTS idx_billing_date ON billing_daily(date)`,
|
|
1318
|
+
`CREATE INDEX IF NOT EXISTS idx_billing_provider ON billing_daily(provider)`,
|
|
1319
|
+
`CREATE TABLE IF NOT EXISTS subscriptions (
|
|
1320
|
+
id TEXT PRIMARY KEY,
|
|
1321
|
+
agent TEXT,
|
|
1322
|
+
provider TEXT NOT NULL,
|
|
1323
|
+
plan TEXT NOT NULL,
|
|
1324
|
+
monthly_fee_usd REAL NOT NULL DEFAULT 0,
|
|
1325
|
+
included_usage_usd REAL NOT NULL DEFAULT 0,
|
|
1326
|
+
billing_cycle_start TEXT,
|
|
1327
|
+
reset_policy TEXT DEFAULT 'monthly',
|
|
1328
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
1329
|
+
created_at TEXT NOT NULL,
|
|
1330
|
+
updated_at TEXT NOT NULL
|
|
1331
|
+
)`,
|
|
1332
|
+
`CREATE TABLE IF NOT EXISTS usage_snapshots (
|
|
1333
|
+
id TEXT PRIMARY KEY,
|
|
1334
|
+
agent TEXT NOT NULL,
|
|
1335
|
+
date TEXT NOT NULL,
|
|
1336
|
+
metric TEXT NOT NULL,
|
|
1337
|
+
value REAL NOT NULL DEFAULT 0,
|
|
1338
|
+
unit TEXT DEFAULT '',
|
|
1339
|
+
machine_id TEXT DEFAULT '',
|
|
1340
|
+
updated_at TEXT NOT NULL
|
|
1341
|
+
)`,
|
|
1342
|
+
`CREATE TABLE IF NOT EXISTS savings_daily (
|
|
1343
|
+
date TEXT NOT NULL,
|
|
1344
|
+
agent TEXT DEFAULT '',
|
|
1345
|
+
api_equivalent_usd REAL NOT NULL DEFAULT 0,
|
|
1346
|
+
subscription_fee_usd REAL NOT NULL DEFAULT 0,
|
|
1347
|
+
included_consumed_usd REAL NOT NULL DEFAULT 0,
|
|
1348
|
+
on_demand_usd REAL NOT NULL DEFAULT 0,
|
|
1349
|
+
saved_usd REAL NOT NULL DEFAULT 0,
|
|
1350
|
+
updated_at TEXT NOT NULL,
|
|
1351
|
+
PRIMARY KEY (date, agent)
|
|
1352
|
+
)`,
|
|
1353
|
+
`CREATE TABLE IF NOT EXISTS machines (
|
|
1354
|
+
machine_id TEXT PRIMARY KEY,
|
|
1355
|
+
hostname TEXT NOT NULL,
|
|
1356
|
+
last_seen_at TEXT,
|
|
1357
|
+
last_push_at TEXT,
|
|
1358
|
+
last_pull_at TEXT,
|
|
1359
|
+
economy_version TEXT,
|
|
1360
|
+
updated_at TEXT NOT NULL
|
|
1361
|
+
)`,
|
|
1362
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS cost_basis TEXT DEFAULT 'estimated'`,
|
|
1363
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
|
|
1364
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
|
|
1365
|
+
`ALTER TABLE requests ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
|
|
1366
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
|
|
1367
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
|
|
1368
|
+
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
|
|
1369
|
+
`CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date)`,
|
|
1370
|
+
`CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`
|
|
1371
|
+
];
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
// src/ingest/billing.ts
|
|
1375
|
+
var exports_billing = {};
|
|
1376
|
+
__export(exports_billing, {
|
|
1377
|
+
syncOpenAIBilling: () => syncOpenAIBilling,
|
|
1378
|
+
syncGeminiBilling: () => syncGeminiBilling,
|
|
1379
|
+
syncAnthropicBilling: () => syncAnthropicBilling
|
|
1380
|
+
});
|
|
1381
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
1382
|
+
function getAnthropicAdminKey() {
|
|
1383
|
+
return process.env["HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY"] ?? process.env["ANTHROPIC_ADMIN_API_KEY"] ?? null;
|
|
1384
|
+
}
|
|
1385
|
+
function getOpenAIAdminKey() {
|
|
1386
|
+
return process.env["HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY"] ?? process.env["OPENAI_ADMIN_API_KEY"] ?? null;
|
|
1387
|
+
}
|
|
1388
|
+
function getGeminiBillingExportPath() {
|
|
1389
|
+
return process.env["HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH"] ?? process.env["HASNAXYZ_ECONOMY_GEMINI_BILLING_EXPORT_PATH"] ?? process.env["GEMINI_BILLING_EXPORT_PATH"] ?? null;
|
|
1390
|
+
}
|
|
1391
|
+
function toISODate(d) {
|
|
1392
|
+
return d.toISOString().substring(0, 10);
|
|
1393
|
+
}
|
|
1394
|
+
function parseDate(value) {
|
|
1395
|
+
if (typeof value !== "string" || !value.trim())
|
|
1396
|
+
return null;
|
|
1397
|
+
const d = new Date(value);
|
|
1398
|
+
if (Number.isNaN(d.getTime()))
|
|
1399
|
+
return value.substring(0, 10);
|
|
1400
|
+
return toISODate(d);
|
|
1401
|
+
}
|
|
1402
|
+
function parseCsv(content) {
|
|
1403
|
+
const lines = content.split(/\r?\n/).filter((line) => line.trim());
|
|
1404
|
+
if (lines.length < 2)
|
|
1405
|
+
return [];
|
|
1406
|
+
const headers = parseCsvLine(lines[0]).map((h) => h.trim());
|
|
1407
|
+
return lines.slice(1).map((line) => {
|
|
1408
|
+
const values = parseCsvLine(line);
|
|
1409
|
+
return Object.fromEntries(headers.map((header, i) => [header, values[i]?.trim() ?? ""]));
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
function parseCsvLine(line) {
|
|
1413
|
+
const values = [];
|
|
1414
|
+
let value = "";
|
|
1415
|
+
let quoted = false;
|
|
1416
|
+
for (let i = 0;i < line.length; i++) {
|
|
1417
|
+
const char = line[i];
|
|
1418
|
+
if (char === '"') {
|
|
1419
|
+
if (quoted && line[i + 1] === '"') {
|
|
1420
|
+
value += '"';
|
|
1421
|
+
i++;
|
|
1422
|
+
} else {
|
|
1423
|
+
quoted = !quoted;
|
|
1424
|
+
}
|
|
1425
|
+
} else if (char === "," && !quoted) {
|
|
1426
|
+
values.push(value);
|
|
1427
|
+
value = "";
|
|
1428
|
+
} else {
|
|
1429
|
+
value += char;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
values.push(value);
|
|
1433
|
+
return values;
|
|
1434
|
+
}
|
|
1435
|
+
function parseBillingRows(content) {
|
|
1436
|
+
const trimmed = content.trim();
|
|
1437
|
+
if (!trimmed)
|
|
1438
|
+
return [];
|
|
1439
|
+
try {
|
|
1440
|
+
const parsed = JSON.parse(trimmed);
|
|
1441
|
+
if (Array.isArray(parsed))
|
|
1442
|
+
return parsed;
|
|
1443
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed["rows"])) {
|
|
1444
|
+
return parsed["rows"];
|
|
1445
|
+
}
|
|
1446
|
+
} catch {}
|
|
1447
|
+
const jsonlRows = [];
|
|
1448
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
1449
|
+
try {
|
|
1450
|
+
const parsed = JSON.parse(line);
|
|
1451
|
+
if (parsed && typeof parsed === "object")
|
|
1452
|
+
jsonlRows.push(parsed);
|
|
1453
|
+
} catch {
|
|
1454
|
+
jsonlRows.length = 0;
|
|
1455
|
+
break;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
if (jsonlRows.length > 0)
|
|
1459
|
+
return jsonlRows;
|
|
1460
|
+
return parseCsv(content);
|
|
1461
|
+
}
|
|
1462
|
+
async function syncAnthropicBilling(db, opts = {}) {
|
|
1463
|
+
const key = getAnthropicAdminKey();
|
|
1464
|
+
if (!key)
|
|
1465
|
+
throw new Error("Missing Anthropic admin key (HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY)");
|
|
1466
|
+
const now = new Date;
|
|
1467
|
+
const end = opts.toDate ? new Date(opts.toDate) : new Date(now.getTime() + 24 * 3600000);
|
|
1468
|
+
const days = opts.days ?? 31;
|
|
1469
|
+
const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
|
|
1470
|
+
const startIso = start.toISOString().replace(/\.\d+/, "").replace(/:\d{2}Z$/, ":00Z");
|
|
1471
|
+
const endIso = end.toISOString().replace(/\.\d+/, "").replace(/:\d{2}Z$/, ":00Z");
|
|
1472
|
+
let totalUsd = 0;
|
|
1473
|
+
const buckets = [];
|
|
1474
|
+
let nextPage;
|
|
1475
|
+
do {
|
|
1476
|
+
const url = new URL("https://api.anthropic.com/v1/organizations/cost_report");
|
|
1477
|
+
url.searchParams.set("starting_at", startIso);
|
|
1478
|
+
url.searchParams.set("ending_at", endIso);
|
|
1479
|
+
url.searchParams.set("bucket_width", "1d");
|
|
1480
|
+
url.searchParams.set("limit", "31");
|
|
1481
|
+
url.searchParams.append("group_by[]", "description");
|
|
1482
|
+
if (nextPage)
|
|
1483
|
+
url.searchParams.set("page", nextPage);
|
|
1484
|
+
const res = await fetch(url.toString(), {
|
|
1485
|
+
headers: { "anthropic-version": "2023-06-01", "x-api-key": key }
|
|
1486
|
+
});
|
|
1487
|
+
const data = await res.json();
|
|
1488
|
+
if (data.error)
|
|
1489
|
+
throw new Error(`Anthropic API: ${data.error.message}`);
|
|
1490
|
+
if (data.data)
|
|
1491
|
+
buckets.push(...data.data);
|
|
1492
|
+
nextPage = data.has_more ? data.next_page : undefined;
|
|
1493
|
+
} while (nextPage);
|
|
1494
|
+
const fromDateStr = toISODate(start);
|
|
1495
|
+
const toDateStr = toISODate(new Date(end.getTime() - 1000));
|
|
1496
|
+
clearBillingRange(db, "anthropic", fromDateStr, toDateStr);
|
|
1497
|
+
const updatedAt = new Date().toISOString();
|
|
1498
|
+
for (const bucket of buckets) {
|
|
1499
|
+
const date = bucket.starting_at.substring(0, 10);
|
|
1500
|
+
for (const r of bucket.results) {
|
|
1501
|
+
const usd = Number(r.amount) / 100;
|
|
1502
|
+
if (usd === 0)
|
|
1503
|
+
continue;
|
|
1504
|
+
const desc = (r.description ?? "unknown").substring(0, 200);
|
|
1505
|
+
upsertBillingDaily(db, { date, provider: "anthropic", description: desc, cost_usd: usd, updated_at: updatedAt });
|
|
1506
|
+
totalUsd += usd;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return { days: buckets.length, totalUsd };
|
|
1510
|
+
}
|
|
1511
|
+
async function syncOpenAIBilling(db, opts = {}) {
|
|
1512
|
+
const key = getOpenAIAdminKey();
|
|
1513
|
+
if (!key)
|
|
1514
|
+
throw new Error("Missing OpenAI admin key (HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY)");
|
|
1515
|
+
const now = new Date;
|
|
1516
|
+
const end = opts.toDate ? new Date(opts.toDate) : now;
|
|
1517
|
+
const days = opts.days ?? 31;
|
|
1518
|
+
const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
|
|
1519
|
+
const startSec = Math.floor(start.getTime() / 1000);
|
|
1520
|
+
const endSec = Math.floor(end.getTime() / 1000);
|
|
1521
|
+
let totalUsd = 0;
|
|
1522
|
+
const buckets = [];
|
|
1523
|
+
let nextPage;
|
|
1524
|
+
do {
|
|
1525
|
+
const url = new URL("https://api.openai.com/v1/organization/costs");
|
|
1526
|
+
url.searchParams.set("start_time", String(startSec));
|
|
1527
|
+
url.searchParams.set("end_time", String(endSec));
|
|
1528
|
+
url.searchParams.set("bucket_width", "1d");
|
|
1529
|
+
url.searchParams.set("limit", "31");
|
|
1530
|
+
url.searchParams.append("group_by[]", "line_item");
|
|
1531
|
+
if (nextPage)
|
|
1532
|
+
url.searchParams.set("page", nextPage);
|
|
1533
|
+
const res = await fetch(url.toString(), {
|
|
1534
|
+
headers: { Authorization: `Bearer ${key}` }
|
|
1535
|
+
});
|
|
1536
|
+
const data = await res.json();
|
|
1537
|
+
if (data.error)
|
|
1538
|
+
throw new Error(`OpenAI API: ${data.error.message}`);
|
|
1539
|
+
if (data.data)
|
|
1540
|
+
buckets.push(...data.data);
|
|
1541
|
+
nextPage = data.has_more ? data.next_page : undefined;
|
|
1542
|
+
} while (nextPage);
|
|
1543
|
+
const fromDateStr = toISODate(start);
|
|
1544
|
+
const toDateStr = toISODate(new Date(end.getTime() - 1000));
|
|
1545
|
+
clearBillingRange(db, "openai", fromDateStr, toDateStr);
|
|
1546
|
+
const updatedAt = new Date().toISOString();
|
|
1547
|
+
for (const bucket of buckets) {
|
|
1548
|
+
const date = new Date(bucket.start_time * 1000).toISOString().substring(0, 10);
|
|
1549
|
+
for (const r of bucket.results) {
|
|
1550
|
+
const usd = Number(r.amount?.value ?? 0);
|
|
1551
|
+
if (usd === 0)
|
|
1552
|
+
continue;
|
|
1553
|
+
const desc = (r.line_item ?? "unknown").substring(0, 200);
|
|
1554
|
+
upsertBillingDaily(db, { date, provider: "openai", description: desc, cost_usd: usd, updated_at: updatedAt });
|
|
1555
|
+
totalUsd += usd;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return { days: buckets.length, totalUsd };
|
|
1559
|
+
}
|
|
1560
|
+
async function syncGeminiBilling(db, opts = {}) {
|
|
1561
|
+
const exportPath = getGeminiBillingExportPath();
|
|
1562
|
+
if (!exportPath) {
|
|
1563
|
+
return {
|
|
1564
|
+
days: 0,
|
|
1565
|
+
totalUsd: 0,
|
|
1566
|
+
skipped: "Missing Gemini billing export path (HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH, HASNAXYZ_ECONOMY_GEMINI_BILLING_EXPORT_PATH, or GEMINI_BILLING_EXPORT_PATH)"
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
const now = new Date;
|
|
1570
|
+
const end = opts.toDate ? new Date(opts.toDate) : now;
|
|
1571
|
+
const days = opts.days ?? 31;
|
|
1572
|
+
const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
|
|
1573
|
+
const fromDateStr = toISODate(start);
|
|
1574
|
+
const toDateStr = toISODate(end);
|
|
1575
|
+
const rows = parseBillingRows(readFileSync10(exportPath, "utf-8"));
|
|
1576
|
+
clearBillingRange(db, "gemini", fromDateStr, toDateStr);
|
|
1577
|
+
const updatedAt = new Date().toISOString();
|
|
1578
|
+
let totalUsd = 0;
|
|
1579
|
+
const seenDays = new Set;
|
|
1580
|
+
for (const row of rows) {
|
|
1581
|
+
const date = parseDate(row["date"] ?? row["usage_start_time"] ?? row["start_time"] ?? row["invoice.month"]);
|
|
1582
|
+
if (!date || date < fromDateStr || date > toDateStr)
|
|
1583
|
+
continue;
|
|
1584
|
+
const rawCost = row["cost_usd"] ?? row["costUsd"] ?? row["cost"] ?? row["amount"];
|
|
1585
|
+
const costUsd = Number(rawCost);
|
|
1586
|
+
if (!Number.isFinite(costUsd) || costUsd === 0)
|
|
1587
|
+
continue;
|
|
1588
|
+
const service = row["service.description"] ?? row["service"] ?? row["provider"] ?? "";
|
|
1589
|
+
const sku = row["sku.description"] ?? row["sku"] ?? row["description"] ?? "Gemini API";
|
|
1590
|
+
const description = `${String(service || "Google AI")}: ${String(sku)}`.substring(0, 200);
|
|
1591
|
+
upsertBillingDaily(db, { date, provider: "gemini", description, cost_usd: costUsd, updated_at: updatedAt });
|
|
1592
|
+
totalUsd += costUsd;
|
|
1593
|
+
seenDays.add(date);
|
|
1594
|
+
}
|
|
1595
|
+
return { days: seenDays.size, totalUsd };
|
|
1596
|
+
}
|
|
1597
|
+
var init_billing = __esm(() => {
|
|
1598
|
+
init_database();
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
// src/lib/open-projects.ts
|
|
1602
|
+
var exports_open_projects = {};
|
|
1603
|
+
__export(exports_open_projects, {
|
|
1604
|
+
syncOpenProjectsRegistry: () => syncOpenProjectsRegistry
|
|
1605
|
+
});
|
|
1606
|
+
async function syncOpenProjectsRegistry(db, listActiveProjects) {
|
|
1607
|
+
let listProjects2 = listActiveProjects;
|
|
1608
|
+
if (!listProjects2) {
|
|
1609
|
+
const projectsApi = await import("@hasna/projects");
|
|
1610
|
+
listProjects2 = projectsApi.listProjects;
|
|
1611
|
+
}
|
|
1612
|
+
const projects = listProjects2({ status: "active", limit: 5000 });
|
|
1613
|
+
let imported = 0;
|
|
1614
|
+
let skipped = 0;
|
|
1615
|
+
for (const project of projects) {
|
|
1616
|
+
if (!project.path) {
|
|
1617
|
+
skipped++;
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
upsertProject(db, {
|
|
1621
|
+
id: project.id,
|
|
1622
|
+
path: project.path,
|
|
1623
|
+
name: project.name,
|
|
1624
|
+
description: project.description,
|
|
1625
|
+
tags: project.tags ?? [],
|
|
1626
|
+
created_at: project.created_at
|
|
1627
|
+
});
|
|
1628
|
+
imported++;
|
|
1629
|
+
}
|
|
1630
|
+
return { imported, skipped };
|
|
1631
|
+
}
|
|
1632
|
+
var init_open_projects = __esm(() => {
|
|
1633
|
+
init_database();
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
// src/lib/config.ts
|
|
1637
|
+
import { existsSync as existsSync10, readFileSync as readFileSync11, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
1638
|
+
import { dirname, join as join9 } from "path";
|
|
1639
|
+
function getConfigPath() {
|
|
1640
|
+
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join9(getDataDir(), "config.json");
|
|
1641
|
+
}
|
|
1642
|
+
function loadConfig() {
|
|
1643
|
+
try {
|
|
1644
|
+
const configPath = getConfigPath();
|
|
1645
|
+
if (existsSync10(configPath)) {
|
|
1646
|
+
const raw = readFileSync11(configPath, "utf-8");
|
|
1647
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
1648
|
+
}
|
|
1649
|
+
} catch {}
|
|
1650
|
+
return { ...DEFAULTS };
|
|
1651
|
+
}
|
|
1652
|
+
var DEFAULTS;
|
|
1653
|
+
var init_config = __esm(() => {
|
|
1654
|
+
init_database();
|
|
1655
|
+
DEFAULTS = {
|
|
1656
|
+
port: 3456,
|
|
1657
|
+
"default-period": "today",
|
|
1658
|
+
"auto-sync": true,
|
|
1659
|
+
"sync-interval": 30,
|
|
1660
|
+
"alert-thresholds": [5, 10, 25, 50, 100],
|
|
1661
|
+
"webhook-url": null
|
|
1662
|
+
};
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
// src/lib/spikes.ts
|
|
1666
|
+
function detectCostSpikes(dailyTotals, opts) {
|
|
1667
|
+
const windowDays = opts?.windowDays ?? 7;
|
|
1668
|
+
const multiplier = opts?.multiplier ?? 2;
|
|
1669
|
+
const sorted = [...dailyTotals].sort((a, b) => a.date.localeCompare(b.date));
|
|
1670
|
+
const spikes = [];
|
|
1671
|
+
for (let i = windowDays;i < sorted.length; i++) {
|
|
1672
|
+
const window = sorted.slice(i - windowDays, i);
|
|
1673
|
+
const avg = window.reduce((s, d) => s + d.cost_usd, 0) / window.length;
|
|
1674
|
+
const current = sorted[i];
|
|
1675
|
+
if (avg > 0 && current.cost_usd > avg * multiplier) {
|
|
1676
|
+
spikes.push({
|
|
1677
|
+
date: current.date,
|
|
1678
|
+
cost_usd: current.cost_usd,
|
|
1679
|
+
average_usd: avg,
|
|
1680
|
+
ratio: current.cost_usd / avg
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return spikes;
|
|
1685
|
+
}
|
|
1686
|
+
function queryRecentSpikes(db, days = 14) {
|
|
1687
|
+
const rows = db.prepare(`
|
|
1688
|
+
SELECT DATE(timestamp) as date, COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
1689
|
+
FROM requests
|
|
1690
|
+
WHERE timestamp >= DATE('now', '-' || ? || ' days')
|
|
1691
|
+
GROUP BY DATE(timestamp)
|
|
1692
|
+
ORDER BY date ASC
|
|
1693
|
+
`).all(days);
|
|
1694
|
+
return detectCostSpikes(rows);
|
|
1695
|
+
}
|
|
1696
|
+
function getTodaySpike(db) {
|
|
1697
|
+
const today = new Date().toISOString().substring(0, 10);
|
|
1698
|
+
const spikes = queryRecentSpikes(db, 14);
|
|
1699
|
+
return spikes.find((s) => s.date === today) ?? null;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/lib/webhooks.ts
|
|
1703
|
+
var exports_webhooks = {};
|
|
1704
|
+
__export(exports_webhooks, {
|
|
1705
|
+
checkAndFireWebhooks: () => checkAndFireWebhooks
|
|
1706
|
+
});
|
|
1707
|
+
async function checkAndFireWebhooks(db) {
|
|
1708
|
+
const config = loadConfig();
|
|
1709
|
+
const url = config["webhook-url"];
|
|
1710
|
+
if (!url)
|
|
1711
|
+
return;
|
|
1712
|
+
const statuses = getBudgetStatuses(db);
|
|
1713
|
+
for (const b of statuses) {
|
|
1714
|
+
if (!b.is_over_alert)
|
|
1715
|
+
continue;
|
|
1716
|
+
const key = `webhook-budget-${b.id}-${b.period}`;
|
|
1717
|
+
const lastFired = getIngestState(db, "webhook", key);
|
|
1718
|
+
const pctBucket = Math.floor(b.percent_used / 10) * 10;
|
|
1719
|
+
if (lastFired === String(pctBucket))
|
|
1720
|
+
continue;
|
|
1721
|
+
const delivered = await fireWebhook(url, {
|
|
1722
|
+
event: "budget_alert",
|
|
1723
|
+
budget_id: b.id,
|
|
1724
|
+
project: b.project_path ?? "global",
|
|
1725
|
+
period: b.period,
|
|
1726
|
+
spend: b.current_spend_usd,
|
|
1727
|
+
limit: b.limit_usd,
|
|
1728
|
+
percent: Math.round(b.percent_used * 10) / 10
|
|
1729
|
+
});
|
|
1730
|
+
if (delivered)
|
|
1731
|
+
setIngestState(db, "webhook", key, String(pctBucket));
|
|
1732
|
+
}
|
|
1733
|
+
const spike = getTodaySpike(db);
|
|
1734
|
+
if (spike) {
|
|
1735
|
+
const key = `webhook-spike-${spike.date}`;
|
|
1736
|
+
if (getIngestState(db, "webhook", key) !== "1") {
|
|
1737
|
+
const delivered = await fireWebhook(url, {
|
|
1738
|
+
event: "cost_spike",
|
|
1739
|
+
date: spike.date,
|
|
1740
|
+
cost_usd: spike.cost_usd,
|
|
1741
|
+
average_usd: spike.average_usd,
|
|
1742
|
+
ratio: Math.round(spike.ratio * 100) / 100
|
|
1743
|
+
});
|
|
1744
|
+
if (delivered)
|
|
1745
|
+
setIngestState(db, "webhook", key, "1");
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
async function fireWebhook(url, payload) {
|
|
1750
|
+
try {
|
|
1751
|
+
const response = await fetch(url, {
|
|
1752
|
+
method: "POST",
|
|
1753
|
+
headers: { "Content-Type": "application/json" },
|
|
1754
|
+
body: JSON.stringify(payload),
|
|
1755
|
+
signal: AbortSignal.timeout(5000)
|
|
1756
|
+
});
|
|
1757
|
+
return response.ok;
|
|
1758
|
+
} catch {
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
var init_webhooks = __esm(() => {
|
|
1763
|
+
init_config();
|
|
1764
|
+
init_database();
|
|
1765
|
+
});
|
|
1766
|
+
|
|
536
1767
|
// src/server/serve.ts
|
|
537
1768
|
init_database();
|
|
1769
|
+
init_pricing();
|
|
1770
|
+
|
|
1771
|
+
// src/lib/agents.ts
|
|
1772
|
+
var AGENTS = [
|
|
1773
|
+
"claude",
|
|
1774
|
+
"takumi",
|
|
1775
|
+
"codex",
|
|
1776
|
+
"gemini",
|
|
1777
|
+
"opencode",
|
|
1778
|
+
"cursor",
|
|
1779
|
+
"pi",
|
|
1780
|
+
"hermes"
|
|
1781
|
+
];
|
|
1782
|
+
function isAgent(value) {
|
|
1783
|
+
return AGENTS.includes(value);
|
|
1784
|
+
}
|
|
538
1785
|
|
|
539
1786
|
// src/ingest/claude.ts
|
|
540
1787
|
init_database();
|
|
@@ -542,15 +1789,119 @@ init_pricing();
|
|
|
542
1789
|
import { readdirSync as readdirSync2, readFileSync, existsSync as existsSync2, statSync as statSync2 } from "fs";
|
|
543
1790
|
import { homedir as homedir2 } from "os";
|
|
544
1791
|
import { join as join2, basename } from "path";
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1792
|
+
|
|
1793
|
+
// src/lib/savings.ts
|
|
1794
|
+
function periodWhere2(period, column) {
|
|
1795
|
+
switch (period) {
|
|
1796
|
+
case "today":
|
|
1797
|
+
return `DATE(${column}) = DATE('now')`;
|
|
1798
|
+
case "yesterday":
|
|
1799
|
+
return `DATE(${column}) = DATE('now', '-1 day')`;
|
|
1800
|
+
case "week":
|
|
1801
|
+
return `${column} >= DATE('now', 'weekday 0', '-7 days')`;
|
|
1802
|
+
case "month":
|
|
1803
|
+
return `${column} >= DATE('now', 'start of month')`;
|
|
1804
|
+
case "year":
|
|
1805
|
+
return `${column} >= DATE('now', 'start of year')`;
|
|
1806
|
+
case "all":
|
|
1807
|
+
return "1=1";
|
|
1808
|
+
}
|
|
551
1809
|
}
|
|
552
|
-
function
|
|
553
|
-
const
|
|
1810
|
+
function prorateMonthlyFee(monthlyFee, period) {
|
|
1811
|
+
const now = new Date;
|
|
1812
|
+
const dayOfMonth = now.getDate();
|
|
1813
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
1814
|
+
switch (period) {
|
|
1815
|
+
case "today":
|
|
1816
|
+
case "yesterday":
|
|
1817
|
+
return monthlyFee / daysInMonth;
|
|
1818
|
+
case "week":
|
|
1819
|
+
return monthlyFee / daysInMonth * 7;
|
|
1820
|
+
case "month":
|
|
1821
|
+
return monthlyFee;
|
|
1822
|
+
case "year":
|
|
1823
|
+
return monthlyFee * 12;
|
|
1824
|
+
case "all":
|
|
1825
|
+
return monthlyFee;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
function computeSavedUsd(apiEquivalent, onDemand, subscriptionFee) {
|
|
1829
|
+
return Math.max(0, apiEquivalent - onDemand - subscriptionFee);
|
|
1830
|
+
}
|
|
1831
|
+
function querySavingsSummary(db, period, agent) {
|
|
1832
|
+
const where = periodWhere2(period, "timestamp");
|
|
1833
|
+
const agentClause = agent ? " AND agent = ?" : "";
|
|
1834
|
+
const params = agent ? [agent] : [];
|
|
1835
|
+
const apiRow = db.prepare(`
|
|
1836
|
+
SELECT COALESCE(SUM(cost_usd), 0) as total
|
|
1837
|
+
FROM requests
|
|
1838
|
+
WHERE ${where}${agentClause}
|
|
1839
|
+
AND COALESCE(cost_basis, 'estimated') IN ('metered_api', 'estimated', 'unknown')
|
|
1840
|
+
`).get(...params);
|
|
1841
|
+
const includedRow = db.prepare(`
|
|
1842
|
+
SELECT COALESCE(SUM(cost_usd), 0) as total
|
|
1843
|
+
FROM requests
|
|
1844
|
+
WHERE ${where}${agentClause}
|
|
1845
|
+
AND cost_basis = 'subscription_included'
|
|
1846
|
+
`).get(...params);
|
|
1847
|
+
const subWhere = periodWhere2(period, "date");
|
|
1848
|
+
const onDemandRow = db.prepare(`
|
|
1849
|
+
SELECT COALESCE(SUM(value), 0) as total
|
|
1850
|
+
FROM usage_snapshots
|
|
1851
|
+
WHERE ${subWhere}${agent ? " AND agent = ?" : ""}
|
|
1852
|
+
AND metric = 'on_demand_usd'
|
|
1853
|
+
`).get(...params);
|
|
1854
|
+
const subs = db.prepare(`
|
|
1855
|
+
SELECT COALESCE(SUM(monthly_fee_usd), 0) as total
|
|
1856
|
+
FROM subscriptions
|
|
1857
|
+
WHERE active = 1${agent ? " AND agent = ?" : ""}
|
|
1858
|
+
`).get(...agent ? [agent] : []);
|
|
1859
|
+
const subscriptionFee = prorateMonthlyFee(subs.total, period);
|
|
1860
|
+
const apiEquivalent = apiRow.total + includedRow.total;
|
|
1861
|
+
const onDemand = onDemandRow.total;
|
|
1862
|
+
const saved = computeSavedUsd(apiEquivalent, onDemand, subscriptionFee);
|
|
1863
|
+
const byAgent = {};
|
|
1864
|
+
if (!agent) {
|
|
1865
|
+
for (const row of db.prepare(`
|
|
1866
|
+
SELECT agent, COALESCE(SUM(cost_usd), 0) as api_eq
|
|
1867
|
+
FROM requests WHERE ${where}
|
|
1868
|
+
GROUP BY agent
|
|
1869
|
+
`).all()) {
|
|
1870
|
+
byAgent[row.agent] = {
|
|
1871
|
+
api_equivalent_usd: row.api_eq,
|
|
1872
|
+
saved_usd: row.api_eq
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return {
|
|
1877
|
+
period,
|
|
1878
|
+
api_equivalent_usd: apiEquivalent,
|
|
1879
|
+
subscription_fee_usd: subscriptionFee,
|
|
1880
|
+
included_consumed_usd: includedRow.total,
|
|
1881
|
+
on_demand_usd: onDemand,
|
|
1882
|
+
saved_usd: saved,
|
|
1883
|
+
by_agent: byAgent
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
function defaultCostBasisForAgent(agent) {
|
|
1887
|
+
if (agent === "claude")
|
|
1888
|
+
return "metered_api";
|
|
1889
|
+
if (agent === "cursor")
|
|
1890
|
+
return "subscription_included";
|
|
1891
|
+
return "estimated";
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/ingest/claude.ts
|
|
1895
|
+
function autoDetectProject(cwd, projects) {
|
|
1896
|
+
return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
|
|
1897
|
+
}
|
|
1898
|
+
var CLAUDE_PROJECTS_DIR = join2(homedir2(), ".claude", "projects");
|
|
1899
|
+
var TAKUMI_PROJECTS_DIR = join2(homedir2(), ".takumi", "projects");
|
|
1900
|
+
function dirNameToPath(dirName) {
|
|
1901
|
+
return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
|
|
1902
|
+
}
|
|
1903
|
+
function collectJsonlFiles(projectDir) {
|
|
1904
|
+
const files = [];
|
|
554
1905
|
function walk(dir) {
|
|
555
1906
|
try {
|
|
556
1907
|
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
@@ -564,30 +1915,37 @@ function collectJsonlFiles(projectDir) {
|
|
|
564
1915
|
walk(projectDir);
|
|
565
1916
|
return files;
|
|
566
1917
|
}
|
|
567
|
-
async function ingestClaude(db, verbose = false,
|
|
568
|
-
|
|
1918
|
+
async function ingestClaude(db, verbose = false, projectsDir = CLAUDE_PROJECTS_DIR) {
|
|
1919
|
+
return ingestJsonlProjects(db, projectsDir, "claude", verbose);
|
|
1920
|
+
}
|
|
1921
|
+
async function ingestTakumi(db, verbose = false, projectsDir = TAKUMI_PROJECTS_DIR) {
|
|
1922
|
+
return ingestJsonlProjects(db, projectsDir, "takumi", verbose);
|
|
1923
|
+
}
|
|
1924
|
+
async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false) {
|
|
1925
|
+
if (!existsSync2(projectsDir)) {
|
|
569
1926
|
if (verbose)
|
|
570
|
-
console.log(
|
|
1927
|
+
console.log(`${agentName} projects dir not found:`, projectsDir);
|
|
571
1928
|
return { files: 0, requests: 0, sessions: 0 };
|
|
572
1929
|
}
|
|
1930
|
+
const machineId = getMachineId();
|
|
573
1931
|
let totalFiles = 0;
|
|
574
1932
|
let totalRequests = 0;
|
|
575
1933
|
const touchedSessions = new Set;
|
|
576
1934
|
const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
|
|
577
|
-
const projectDirs = readdirSync2(
|
|
1935
|
+
const projectDirs = readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
578
1936
|
for (const projectDirEntry of projectDirs) {
|
|
579
|
-
const projectDirPath = join2(
|
|
1937
|
+
const projectDirPath = join2(projectsDir, projectDirEntry.name);
|
|
580
1938
|
const projectPath = dirNameToPath(projectDirEntry.name);
|
|
581
1939
|
const jsonlFiles = collectJsonlFiles(projectDirPath);
|
|
582
1940
|
for (const filePath of jsonlFiles) {
|
|
583
|
-
const stateKey = filePath.replace(
|
|
1941
|
+
const stateKey = filePath.replace(projectsDir, "");
|
|
584
1942
|
let fileMtime = "0";
|
|
585
1943
|
try {
|
|
586
1944
|
fileMtime = statSync2(filePath).mtimeMs.toString();
|
|
587
1945
|
} catch {
|
|
588
1946
|
continue;
|
|
589
1947
|
}
|
|
590
|
-
const processed = getIngestState(db,
|
|
1948
|
+
const processed = getIngestState(db, agentName, stateKey);
|
|
591
1949
|
if (processed === fileMtime)
|
|
592
1950
|
continue;
|
|
593
1951
|
let lines;
|
|
@@ -622,26 +1980,38 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
|
|
|
622
1980
|
continue;
|
|
623
1981
|
const inputTokens = usage.input_tokens ?? 0;
|
|
624
1982
|
const outputTokens = usage.output_tokens ?? 0;
|
|
625
|
-
const
|
|
1983
|
+
const cacheWrite5mTokens = usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? 0;
|
|
1984
|
+
const cacheWrite1hTokens = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
1985
|
+
const cacheWriteTokens = cacheWrite5mTokens + cacheWrite1hTokens;
|
|
626
1986
|
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
|
627
1987
|
const timestamp = entry.timestamp ?? new Date().toISOString();
|
|
628
|
-
if (inputTokens + outputTokens + cacheWriteTokens === 0)
|
|
1988
|
+
if (inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens === 0)
|
|
629
1989
|
continue;
|
|
630
|
-
|
|
631
|
-
|
|
1990
|
+
let costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens);
|
|
1991
|
+
costUsd = applyClaudeModifiers(costUsd, model, usage, entry);
|
|
1992
|
+
const serverToolUse = usage.server_tool_use;
|
|
1993
|
+
if (serverToolUse?.web_search_requests) {
|
|
1994
|
+
costUsd += serverToolUse.web_search_requests * 0.01;
|
|
1995
|
+
}
|
|
1996
|
+
const sourceRequestId = entry.requestId ?? entry.request_id ?? entry.message.id ?? entry.uuid ?? `${sessionId}-${timestamp}`;
|
|
1997
|
+
const reqId = `${agentName}-${sourceRequestId}`;
|
|
632
1998
|
upsertRequest(db, {
|
|
633
1999
|
id: reqId,
|
|
634
|
-
agent:
|
|
2000
|
+
agent: agentName,
|
|
635
2001
|
session_id: sessionId,
|
|
636
2002
|
model,
|
|
637
2003
|
input_tokens: inputTokens,
|
|
638
2004
|
output_tokens: outputTokens,
|
|
639
2005
|
cache_read_tokens: cacheReadTokens,
|
|
640
2006
|
cache_create_tokens: cacheWriteTokens,
|
|
2007
|
+
cache_create_5m_tokens: cacheWrite5mTokens,
|
|
2008
|
+
cache_create_1h_tokens: cacheWrite1hTokens,
|
|
641
2009
|
cost_usd: costUsd,
|
|
2010
|
+
cost_basis: defaultCostBasisForAgent(agentName),
|
|
642
2011
|
duration_ms: 0,
|
|
643
2012
|
timestamp,
|
|
644
|
-
source_request_id:
|
|
2013
|
+
source_request_id: sourceRequestId,
|
|
2014
|
+
machine_id: machineId
|
|
645
2015
|
});
|
|
646
2016
|
if (!touchedSessions.has(sessionId)) {
|
|
647
2017
|
const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
|
|
@@ -650,14 +2020,15 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
|
|
|
650
2020
|
const detectedProject = autoDetectProject(effectiveCwd, registeredProjects);
|
|
651
2021
|
const session = {
|
|
652
2022
|
id: sessionId,
|
|
653
|
-
agent:
|
|
2023
|
+
agent: agentName,
|
|
654
2024
|
project_path: detectedProject ? detectedProject.path : effectiveCwd,
|
|
655
2025
|
project_name: detectedProject ? detectedProject.name : "",
|
|
656
2026
|
started_at: timestamp,
|
|
657
2027
|
ended_at: null,
|
|
658
2028
|
total_cost_usd: 0,
|
|
659
2029
|
total_tokens: 0,
|
|
660
|
-
request_count: 0
|
|
2030
|
+
request_count: 0,
|
|
2031
|
+
machine_id: machineId
|
|
661
2032
|
};
|
|
662
2033
|
upsertSession(db, session);
|
|
663
2034
|
}
|
|
@@ -665,7 +2036,7 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
|
|
|
665
2036
|
}
|
|
666
2037
|
totalRequests++;
|
|
667
2038
|
}
|
|
668
|
-
setIngestState(db,
|
|
2039
|
+
setIngestState(db, agentName, stateKey, fileMtime);
|
|
669
2040
|
totalFiles++;
|
|
670
2041
|
}
|
|
671
2042
|
}
|
|
@@ -674,66 +2045,1369 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
|
|
|
674
2045
|
}
|
|
675
2046
|
return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
|
|
676
2047
|
}
|
|
2048
|
+
function applyClaudeModifiers(costUsd, model, usage, entry) {
|
|
2049
|
+
let multiplier = 1;
|
|
2050
|
+
const speed = usage.speed ?? entry.message?.speed ?? entry.speed;
|
|
2051
|
+
if (speed === "fast" && model.includes("opus-4-6")) {
|
|
2052
|
+
multiplier *= 6;
|
|
2053
|
+
}
|
|
2054
|
+
const inferenceGeo = usage.inference_geo ?? entry.message?.inference_geo ?? entry.inference_geo;
|
|
2055
|
+
if (inferenceGeo && ["us", "us-only", "us_only"].includes(inferenceGeo) && supportsClaudeDataResidencyPricing(model)) {
|
|
2056
|
+
multiplier *= 1.1;
|
|
2057
|
+
}
|
|
2058
|
+
return costUsd * multiplier;
|
|
2059
|
+
}
|
|
2060
|
+
function supportsClaudeDataResidencyPricing(model) {
|
|
2061
|
+
const normalized = normalizeModelName(model);
|
|
2062
|
+
const match = normalized.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?(?:-|$)/);
|
|
2063
|
+
if (!match)
|
|
2064
|
+
return false;
|
|
2065
|
+
const major = Number(match[2]);
|
|
2066
|
+
const minor = match[3] ? Number(match[3]) : 0;
|
|
2067
|
+
return major > 4 || major === 4 && minor >= 6;
|
|
2068
|
+
}
|
|
677
2069
|
|
|
678
2070
|
// src/ingest/codex.ts
|
|
679
2071
|
init_database();
|
|
2072
|
+
init_pricing();
|
|
680
2073
|
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
681
2074
|
import { homedir as homedir3 } from "os";
|
|
682
2075
|
import { join as join3, basename as basename2 } from "path";
|
|
683
|
-
import { Database as
|
|
684
|
-
var
|
|
685
|
-
var
|
|
2076
|
+
import { Database as BunDatabase } from "bun:sqlite";
|
|
2077
|
+
var DEFAULT_CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
|
|
2078
|
+
var DEFAULT_CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
|
|
2079
|
+
var CODEX_INGEST_VERSION = "rollout-token-dedupe-v2";
|
|
2080
|
+
function codexDbPath() {
|
|
2081
|
+
return process.env["HASNA_ECONOMY_CODEX_DB_PATH"] ?? DEFAULT_CODEX_DB_PATH;
|
|
2082
|
+
}
|
|
2083
|
+
function codexConfigPath() {
|
|
2084
|
+
return process.env["HASNA_ECONOMY_CODEX_CONFIG_PATH"] ?? DEFAULT_CODEX_CONFIG_PATH;
|
|
2085
|
+
}
|
|
2086
|
+
function readCodexModel() {
|
|
2087
|
+
const configPath = codexConfigPath();
|
|
2088
|
+
if (!existsSync3(configPath))
|
|
2089
|
+
return "gpt-5-codex";
|
|
2090
|
+
try {
|
|
2091
|
+
const content = readFileSync2(configPath, "utf-8");
|
|
2092
|
+
const match = content.match(/^model\s*=\s*"([^"]+)"/m);
|
|
2093
|
+
return match?.[1] ?? "gpt-5-codex";
|
|
2094
|
+
} catch {
|
|
2095
|
+
return "gpt-5-codex";
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function buildThreadQuery(codexDb) {
|
|
2099
|
+
const cols = new Set(codexDb.prepare(`PRAGMA table_info(threads)`).all().map((c) => c.name));
|
|
2100
|
+
const modelSelect = cols.has("model") ? "model" : "NULL AS model";
|
|
2101
|
+
const rolloutSelect = cols.has("rollout_path") ? "rollout_path" : "NULL AS rollout_path";
|
|
2102
|
+
const providerSelect = cols.has("model_provider") ? "model_provider" : "NULL AS model_provider";
|
|
2103
|
+
return `
|
|
2104
|
+
SELECT id, ${rolloutSelect}, cwd, created_at, updated_at, tokens_used, title,
|
|
2105
|
+
${providerSelect}, ${modelSelect}
|
|
2106
|
+
FROM threads WHERE tokens_used > 0
|
|
2107
|
+
`;
|
|
2108
|
+
}
|
|
2109
|
+
function readTokenEvents(rolloutPath) {
|
|
2110
|
+
if (!rolloutPath || !existsSync3(rolloutPath))
|
|
2111
|
+
return [];
|
|
2112
|
+
const events = [];
|
|
2113
|
+
const seen = new Set;
|
|
2114
|
+
for (const line of readFileSync2(rolloutPath, "utf-8").split(`
|
|
2115
|
+
`)) {
|
|
2116
|
+
if (!line.trim())
|
|
2117
|
+
continue;
|
|
2118
|
+
let entry;
|
|
2119
|
+
try {
|
|
2120
|
+
entry = JSON.parse(line);
|
|
2121
|
+
} catch {
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
if (!entry || typeof entry !== "object")
|
|
2125
|
+
continue;
|
|
2126
|
+
const payload = entry["payload"];
|
|
2127
|
+
if (!payload || payload["type"] !== "token_count")
|
|
2128
|
+
continue;
|
|
2129
|
+
const info = payload["info"];
|
|
2130
|
+
const usage = info?.["last_token_usage"];
|
|
2131
|
+
if (!usage)
|
|
2132
|
+
continue;
|
|
2133
|
+
const total = usage.total_tokens ?? (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
|
|
2134
|
+
if (total <= 0)
|
|
2135
|
+
continue;
|
|
2136
|
+
const key = JSON.stringify(usage);
|
|
2137
|
+
if (seen.has(key))
|
|
2138
|
+
continue;
|
|
2139
|
+
seen.add(key);
|
|
2140
|
+
const timestamp = entry["timestamp"];
|
|
2141
|
+
events.push({ usage, timestamp: typeof timestamp === "string" ? timestamp : undefined });
|
|
2142
|
+
}
|
|
2143
|
+
return events;
|
|
2144
|
+
}
|
|
2145
|
+
function fallbackEvents(totalTokens) {
|
|
2146
|
+
const inputTokens = Math.floor(totalTokens * 0.6);
|
|
2147
|
+
return [{
|
|
2148
|
+
usage: {
|
|
2149
|
+
input_tokens: inputTokens,
|
|
2150
|
+
cached_input_tokens: 0,
|
|
2151
|
+
output_tokens: totalTokens - inputTokens,
|
|
2152
|
+
total_tokens: totalTokens
|
|
2153
|
+
}
|
|
2154
|
+
}];
|
|
2155
|
+
}
|
|
686
2156
|
async function ingestCodex(db, verbose = false) {
|
|
687
|
-
|
|
2157
|
+
const dbPath = codexDbPath();
|
|
2158
|
+
if (!existsSync3(dbPath)) {
|
|
688
2159
|
if (verbose)
|
|
689
|
-
console.log("Codex DB not found:",
|
|
690
|
-
return { sessions: 0 };
|
|
2160
|
+
console.log("Codex DB not found:", dbPath);
|
|
2161
|
+
return { sessions: 0, requests: 0 };
|
|
691
2162
|
}
|
|
2163
|
+
const machineId = getMachineId();
|
|
692
2164
|
let codexDb = null;
|
|
693
2165
|
let ingested = 0;
|
|
2166
|
+
let requests = 0;
|
|
694
2167
|
try {
|
|
695
|
-
codexDb = new
|
|
696
|
-
const threads = codexDb.prepare(
|
|
2168
|
+
codexDb = new BunDatabase(dbPath, { readonly: true });
|
|
2169
|
+
const threads = codexDb.prepare(buildThreadQuery(codexDb)).all();
|
|
697
2170
|
for (const thread of threads) {
|
|
698
|
-
const
|
|
699
|
-
const
|
|
700
|
-
|
|
2171
|
+
const model = thread.model ?? readCodexModel();
|
|
2172
|
+
const stateValue = `${CODEX_INGEST_VERSION}:${thread.updated_at}:${thread.tokens_used}:${model}`;
|
|
2173
|
+
const processed = getIngestState(db, "codex", thread.id);
|
|
2174
|
+
if (processed === stateValue)
|
|
701
2175
|
continue;
|
|
702
|
-
const costUsd = 0;
|
|
703
2176
|
const projectPath = thread.cwd ?? "";
|
|
704
2177
|
const projectName = projectPath ? basename2(projectPath) : "unknown";
|
|
2178
|
+
const sessionId = `codex-${thread.id}`;
|
|
705
2179
|
const startedAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString();
|
|
706
2180
|
const endedAt = thread.updated_at ? new Date(thread.updated_at * 1000).toISOString() : null;
|
|
707
2181
|
upsertSession(db, {
|
|
708
|
-
id:
|
|
2182
|
+
id: sessionId,
|
|
709
2183
|
agent: "codex",
|
|
710
2184
|
project_path: projectPath,
|
|
711
2185
|
project_name: projectName,
|
|
712
2186
|
started_at: startedAt,
|
|
713
2187
|
ended_at: endedAt,
|
|
714
|
-
total_cost_usd:
|
|
715
|
-
total_tokens:
|
|
716
|
-
request_count:
|
|
2188
|
+
total_cost_usd: 0,
|
|
2189
|
+
total_tokens: 0,
|
|
2190
|
+
request_count: 0,
|
|
2191
|
+
machine_id: machineId
|
|
2192
|
+
});
|
|
2193
|
+
const events = readTokenEvents(thread.rollout_path);
|
|
2194
|
+
const tokenEvents = events.length > 0 ? events : fallbackEvents(thread.tokens_used);
|
|
2195
|
+
db.prepare(`DELETE FROM requests WHERE session_id = ?`).run(sessionId);
|
|
2196
|
+
tokenEvents.forEach((event, index) => {
|
|
2197
|
+
const usage = event.usage;
|
|
2198
|
+
const inputTotal = usage.input_tokens ?? 0;
|
|
2199
|
+
const cacheReadTokens = usage.cached_input_tokens ?? 0;
|
|
2200
|
+
const inputTokens = Math.max(inputTotal - cacheReadTokens, 0);
|
|
2201
|
+
const outputTokens = usage.output_tokens ?? Math.max((usage.total_tokens ?? 0) - inputTotal, 0);
|
|
2202
|
+
const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, 0);
|
|
2203
|
+
const timestamp = event.timestamp ?? (thread.created_at ? new Date(thread.created_at * 1000 + index).toISOString() : new Date().toISOString());
|
|
2204
|
+
const requestId = `${sessionId}-${index}`;
|
|
2205
|
+
upsertRequest(db, {
|
|
2206
|
+
id: requestId,
|
|
2207
|
+
agent: "codex",
|
|
2208
|
+
session_id: sessionId,
|
|
2209
|
+
model,
|
|
2210
|
+
input_tokens: inputTokens,
|
|
2211
|
+
output_tokens: outputTokens,
|
|
2212
|
+
cache_read_tokens: cacheReadTokens,
|
|
2213
|
+
cache_create_tokens: 0,
|
|
2214
|
+
cost_usd: costUsd,
|
|
2215
|
+
cost_basis: defaultCostBasisForAgent("codex"),
|
|
2216
|
+
duration_ms: 0,
|
|
2217
|
+
timestamp,
|
|
2218
|
+
source_request_id: requestId,
|
|
2219
|
+
machine_id: machineId
|
|
2220
|
+
});
|
|
2221
|
+
requests++;
|
|
717
2222
|
});
|
|
718
|
-
|
|
2223
|
+
rollupSession(db, sessionId);
|
|
2224
|
+
setIngestState(db, "codex", thread.id, stateValue);
|
|
719
2225
|
ingested++;
|
|
720
2226
|
if (verbose)
|
|
721
|
-
console.log(`Codex session ${thread.id}: ${thread.tokens_used} tokens
|
|
2227
|
+
console.log(`Codex session ${thread.id}: ${thread.tokens_used} tokens on ${model}`);
|
|
722
2228
|
}
|
|
723
2229
|
} finally {
|
|
724
2230
|
codexDb?.close();
|
|
725
2231
|
}
|
|
726
|
-
return { sessions: ingested };
|
|
2232
|
+
return { sessions: ingested, requests };
|
|
727
2233
|
}
|
|
728
2234
|
|
|
729
|
-
// src/
|
|
2235
|
+
// src/ingest/gemini.ts
|
|
2236
|
+
init_database();
|
|
2237
|
+
init_pricing();
|
|
2238
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync3, existsSync as existsSync4, statSync as statSync3 } from "fs";
|
|
2239
|
+
import { homedir as homedir4 } from "os";
|
|
2240
|
+
import { join as join4, basename as basename3 } from "path";
|
|
2241
|
+
var DEFAULT_GEMINI_TMP_DIR = join4(homedir4(), ".gemini", "tmp");
|
|
2242
|
+
var DEFAULT_GEMINI_HISTORY_DIR = join4(homedir4(), ".gemini", "history");
|
|
2243
|
+
function geminiTmpDir() {
|
|
2244
|
+
return process.env["HASNA_ECONOMY_GEMINI_TMP_DIR"] ?? DEFAULT_GEMINI_TMP_DIR;
|
|
2245
|
+
}
|
|
2246
|
+
function geminiHistoryDir() {
|
|
2247
|
+
return process.env["HASNA_ECONOMY_GEMINI_HISTORY_DIR"] ?? DEFAULT_GEMINI_HISTORY_DIR;
|
|
2248
|
+
}
|
|
2249
|
+
function numberField(...values) {
|
|
2250
|
+
for (const value of values) {
|
|
2251
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
2252
|
+
return value;
|
|
2253
|
+
}
|
|
2254
|
+
return 0;
|
|
2255
|
+
}
|
|
2256
|
+
function listProjectDirs(...roots) {
|
|
2257
|
+
const dirs = new Set;
|
|
2258
|
+
for (const root of roots) {
|
|
2259
|
+
if (!existsSync4(root))
|
|
2260
|
+
continue;
|
|
2261
|
+
try {
|
|
2262
|
+
for (const entry of readdirSync3(root, { withFileTypes: true })) {
|
|
2263
|
+
if (entry.isDirectory())
|
|
2264
|
+
dirs.add(join4(root, entry.name));
|
|
2265
|
+
}
|
|
2266
|
+
} catch {}
|
|
2267
|
+
}
|
|
2268
|
+
return [...dirs];
|
|
2269
|
+
}
|
|
2270
|
+
function projectRoot(projectDir, chatData) {
|
|
2271
|
+
if (chatData.projectPath)
|
|
2272
|
+
return chatData.projectPath;
|
|
2273
|
+
if (chatData.project_path)
|
|
2274
|
+
return chatData.project_path;
|
|
2275
|
+
const rootFile = join4(projectDir, ".project_root");
|
|
2276
|
+
try {
|
|
2277
|
+
if (existsSync4(rootFile))
|
|
2278
|
+
return readFileSync3(rootFile, "utf-8").trim();
|
|
2279
|
+
} catch {}
|
|
2280
|
+
return "";
|
|
2281
|
+
}
|
|
2282
|
+
async function ingestGemini(db, verbose) {
|
|
2283
|
+
const tmpDir = geminiTmpDir();
|
|
2284
|
+
const historyDir = geminiHistoryDir();
|
|
2285
|
+
if (!existsSync4(tmpDir) && !existsSync4(historyDir)) {
|
|
2286
|
+
if (verbose)
|
|
2287
|
+
console.log("Gemini tmp/history dirs not found:", tmpDir, historyDir);
|
|
2288
|
+
return { sessions: 0, requests: 0 };
|
|
2289
|
+
}
|
|
2290
|
+
const machineId = getMachineId();
|
|
2291
|
+
let totalSessions = 0;
|
|
2292
|
+
let totalRequests = 0;
|
|
2293
|
+
const touchedSessions = new Set;
|
|
2294
|
+
const projectDirs = listProjectDirs(tmpDir, historyDir);
|
|
2295
|
+
for (const projectDir of projectDirs) {
|
|
2296
|
+
const chatsDir = join4(projectDir, "chats");
|
|
2297
|
+
if (!existsSync4(chatsDir))
|
|
2298
|
+
continue;
|
|
2299
|
+
let chatFiles = [];
|
|
2300
|
+
try {
|
|
2301
|
+
chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join4(chatsDir, f));
|
|
2302
|
+
} catch {
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
for (const filePath of chatFiles) {
|
|
2306
|
+
const stateKey = filePath.replace(homedir4(), "~");
|
|
2307
|
+
let fileMtime = "0";
|
|
2308
|
+
try {
|
|
2309
|
+
fileMtime = statSync3(filePath).mtimeMs.toString();
|
|
2310
|
+
} catch {
|
|
2311
|
+
continue;
|
|
2312
|
+
}
|
|
2313
|
+
const processed = getIngestState(db, "gemini", stateKey);
|
|
2314
|
+
if (processed === fileMtime)
|
|
2315
|
+
continue;
|
|
2316
|
+
let chatData;
|
|
2317
|
+
try {
|
|
2318
|
+
chatData = JSON.parse(readFileSync3(filePath, "utf-8"));
|
|
2319
|
+
} catch {
|
|
2320
|
+
continue;
|
|
2321
|
+
}
|
|
2322
|
+
const sessionId = chatData.sessionId ?? chatData.id ?? basename3(filePath, ".json");
|
|
2323
|
+
if (!sessionId)
|
|
2324
|
+
continue;
|
|
2325
|
+
const startTime = chatData.startTime ?? new Date().toISOString();
|
|
2326
|
+
const projectPath = projectRoot(projectDir, chatData);
|
|
2327
|
+
const projectName = projectPath ? basename3(projectPath) : "";
|
|
2328
|
+
const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
|
|
2329
|
+
if (!existing) {
|
|
2330
|
+
const session = {
|
|
2331
|
+
id: sessionId,
|
|
2332
|
+
agent: "gemini",
|
|
2333
|
+
project_path: projectPath,
|
|
2334
|
+
project_name: projectName,
|
|
2335
|
+
started_at: startTime,
|
|
2336
|
+
ended_at: chatData.lastUpdated ?? null,
|
|
2337
|
+
total_cost_usd: 0,
|
|
2338
|
+
total_tokens: 0,
|
|
2339
|
+
request_count: 0,
|
|
2340
|
+
machine_id: machineId
|
|
2341
|
+
};
|
|
2342
|
+
upsertSession(db, session);
|
|
2343
|
+
totalSessions++;
|
|
2344
|
+
}
|
|
2345
|
+
touchedSessions.add(sessionId);
|
|
2346
|
+
for (const [index, message] of (chatData.messages ?? []).entries()) {
|
|
2347
|
+
const usage = message.usage ?? message.usageMetadata ?? message.response?.usageMetadata;
|
|
2348
|
+
if (!usage)
|
|
2349
|
+
continue;
|
|
2350
|
+
const model = message.model ?? message.response?.modelVersion ?? message.response?.model ?? chatData.model;
|
|
2351
|
+
if (!model)
|
|
2352
|
+
continue;
|
|
2353
|
+
const toolUsePromptTokens = numberField(usage.toolUsePromptTokenCount, usage.tool_use_prompt_token_count);
|
|
2354
|
+
const inputTotal = numberField(usage.inputTokens, usage.input_tokens, usage.promptTokenCount, usage.prompt_token_count) + toolUsePromptTokens;
|
|
2355
|
+
const cacheReadTokens = numberField(usage.cachedInputTokens, usage.cache_read_tokens, usage.cachedContentTokenCount, usage.cached_content_token_count);
|
|
2356
|
+
const inputTokens = Math.max(inputTotal - cacheReadTokens, 0);
|
|
2357
|
+
const thoughtsTokens = numberField(usage.thoughtsTokenCount, usage.thoughts_token_count);
|
|
2358
|
+
const outputTokens = numberField(usage.outputTokens, usage.output_tokens, usage.candidatesTokenCount, usage.candidates_token_count) + thoughtsTokens;
|
|
2359
|
+
const totalTokens = numberField(usage.totalTokens, usage.total_tokens, usage.totalTokenCount, usage.total_token_count);
|
|
2360
|
+
if (inputTokens + outputTokens + cacheReadTokens + totalTokens === 0)
|
|
2361
|
+
continue;
|
|
2362
|
+
const computedCost = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, 0);
|
|
2363
|
+
const costUsd = numberField(message.costUsd, message.cost_usd) || computedCost;
|
|
2364
|
+
const timestamp = message.timestamp ?? chatData.lastUpdated ?? startTime;
|
|
2365
|
+
const requestId = `gemini-${sessionId}-${message.id ?? index}`;
|
|
2366
|
+
upsertRequest(db, {
|
|
2367
|
+
id: requestId,
|
|
2368
|
+
agent: "gemini",
|
|
2369
|
+
session_id: sessionId,
|
|
2370
|
+
model,
|
|
2371
|
+
input_tokens: inputTokens,
|
|
2372
|
+
output_tokens: outputTokens,
|
|
2373
|
+
cache_read_tokens: cacheReadTokens,
|
|
2374
|
+
cache_create_tokens: 0,
|
|
2375
|
+
cost_usd: costUsd,
|
|
2376
|
+
cost_basis: defaultCostBasisForAgent("gemini"),
|
|
2377
|
+
duration_ms: 0,
|
|
2378
|
+
timestamp,
|
|
2379
|
+
source_request_id: message.id ?? requestId,
|
|
2380
|
+
machine_id: machineId
|
|
2381
|
+
});
|
|
2382
|
+
totalRequests++;
|
|
2383
|
+
}
|
|
2384
|
+
setIngestState(db, "gemini", stateKey, fileMtime);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
for (const sessionId of touchedSessions) {
|
|
2388
|
+
rollupSession(db, sessionId);
|
|
2389
|
+
}
|
|
2390
|
+
return { sessions: totalSessions, requests: totalRequests };
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// src/ingest/opencode.ts
|
|
2394
|
+
init_database();
|
|
730
2395
|
init_pricing();
|
|
2396
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
|
|
2397
|
+
import { homedir as homedir5 } from "os";
|
|
2398
|
+
import { join as join5 } from "path";
|
|
2399
|
+
var OPENCODE_STORAGE = join5(homedir5(), ".local", "share", "opencode", "storage");
|
|
2400
|
+
function walkJsonFiles(dir, acc = []) {
|
|
2401
|
+
if (!existsSync5(dir))
|
|
2402
|
+
return acc;
|
|
2403
|
+
for (const entry of readdirSync4(dir, { withFileTypes: true })) {
|
|
2404
|
+
const p = join5(dir, entry.name);
|
|
2405
|
+
if (entry.isDirectory())
|
|
2406
|
+
walkJsonFiles(p, acc);
|
|
2407
|
+
else if (entry.name.endsWith(".json"))
|
|
2408
|
+
acc.push(p);
|
|
2409
|
+
}
|
|
2410
|
+
return acc;
|
|
2411
|
+
}
|
|
2412
|
+
function parseSessionIdFromPath(filePath) {
|
|
2413
|
+
const parts = filePath.split("/");
|
|
2414
|
+
const msgIdx = parts.indexOf("message");
|
|
2415
|
+
if (msgIdx >= 0 && parts[msgIdx + 1])
|
|
2416
|
+
return parts[msgIdx + 1];
|
|
2417
|
+
const sessionIdx = parts.indexOf("session");
|
|
2418
|
+
if (sessionIdx >= 0 && parts[parts.length - 1]?.endsWith(".json")) {
|
|
2419
|
+
return parts[parts.length - 1].replace(/\.json$/, "");
|
|
2420
|
+
}
|
|
2421
|
+
return null;
|
|
2422
|
+
}
|
|
2423
|
+
async function ingestOpenCode(db, verbose = false) {
|
|
2424
|
+
const messageDir = join5(OPENCODE_STORAGE, "message");
|
|
2425
|
+
const files = walkJsonFiles(messageDir);
|
|
2426
|
+
let requests = 0;
|
|
2427
|
+
const touched = new Set;
|
|
2428
|
+
const machineId = getMachineId();
|
|
2429
|
+
const now = new Date().toISOString();
|
|
2430
|
+
for (const file of files) {
|
|
2431
|
+
const mtime = statSync4(file).mtimeMs;
|
|
2432
|
+
const stateKey = file;
|
|
2433
|
+
const prev = getIngestState(db, "opencode", stateKey);
|
|
2434
|
+
if (prev && Number(prev) >= mtime)
|
|
2435
|
+
continue;
|
|
2436
|
+
let parsed;
|
|
2437
|
+
try {
|
|
2438
|
+
parsed = JSON.parse(readFileSync4(file, "utf-8"));
|
|
2439
|
+
} catch {
|
|
2440
|
+
continue;
|
|
2441
|
+
}
|
|
2442
|
+
if (parsed.role !== "assistant")
|
|
2443
|
+
continue;
|
|
2444
|
+
const usage = parsed.usage;
|
|
2445
|
+
if (!usage)
|
|
2446
|
+
continue;
|
|
2447
|
+
const sessionId = parseSessionIdFromPath(file) ?? `opencode-${statSync4(file).ino}`;
|
|
2448
|
+
const model = normalizeModelName(parsed.model ?? "unknown");
|
|
2449
|
+
const input = usage.inputTokens ?? 0;
|
|
2450
|
+
const output = usage.outputTokens ?? 0;
|
|
2451
|
+
const cacheRead = usage.cacheReadTokens ?? 0;
|
|
2452
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0;
|
|
2453
|
+
if (input + output + cacheRead + cacheWrite === 0 && !usage.cost)
|
|
2454
|
+
continue;
|
|
2455
|
+
const timestamp = usage && parsed.time?.created ? new Date(parsed.time.created).toISOString() : new Date(statSync4(file).mtime).toISOString();
|
|
2456
|
+
const sourceId = file.replace(OPENCODE_STORAGE, "");
|
|
2457
|
+
const reqId = `opencode-${sourceId}`;
|
|
2458
|
+
const costUsd = usage.cost ?? computeCostFromDb(db, model, input, output, cacheRead, cacheWrite, 0);
|
|
2459
|
+
upsertRequest(db, {
|
|
2460
|
+
id: reqId,
|
|
2461
|
+
agent: "opencode",
|
|
2462
|
+
session_id: sessionId,
|
|
2463
|
+
model,
|
|
2464
|
+
input_tokens: input,
|
|
2465
|
+
output_tokens: output,
|
|
2466
|
+
cache_read_tokens: cacheRead,
|
|
2467
|
+
cache_create_tokens: cacheWrite,
|
|
2468
|
+
cost_usd: costUsd,
|
|
2469
|
+
cost_basis: defaultCostBasisForAgent("opencode"),
|
|
2470
|
+
duration_ms: 0,
|
|
2471
|
+
timestamp,
|
|
2472
|
+
source_request_id: sourceId,
|
|
2473
|
+
machine_id: machineId,
|
|
2474
|
+
updated_at: now
|
|
2475
|
+
});
|
|
2476
|
+
requests++;
|
|
2477
|
+
if (!touched.has(sessionId)) {
|
|
2478
|
+
upsertSession(db, {
|
|
2479
|
+
id: sessionId,
|
|
2480
|
+
agent: "opencode",
|
|
2481
|
+
project_path: "",
|
|
2482
|
+
project_name: "",
|
|
2483
|
+
started_at: timestamp,
|
|
2484
|
+
ended_at: null,
|
|
2485
|
+
total_cost_usd: 0,
|
|
2486
|
+
total_tokens: 0,
|
|
2487
|
+
request_count: 0,
|
|
2488
|
+
machine_id: machineId,
|
|
2489
|
+
updated_at: now
|
|
2490
|
+
});
|
|
2491
|
+
touched.add(sessionId);
|
|
2492
|
+
}
|
|
2493
|
+
setIngestState(db, "opencode", stateKey, String(mtime));
|
|
2494
|
+
if (verbose)
|
|
2495
|
+
console.log(` opencode: ${reqId} ${model} $${costUsd.toFixed(4)}`);
|
|
2496
|
+
}
|
|
2497
|
+
for (const sid of touched)
|
|
2498
|
+
rollupSession(db, sid);
|
|
2499
|
+
return { files: files.length, requests, sessions: touched.size };
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// src/ingest/cursor.ts
|
|
2503
|
+
init_database();
|
|
2504
|
+
function getCursorSessionToken() {
|
|
2505
|
+
return process.env["CURSOR_SESSION_TOKEN"] ?? process.env["CURSOR_API_TOKEN"] ?? null;
|
|
2506
|
+
}
|
|
2507
|
+
async function cursorFetch(path, token) {
|
|
2508
|
+
try {
|
|
2509
|
+
const res = await fetch(`https://cursor.com${path}`, {
|
|
2510
|
+
headers: {
|
|
2511
|
+
Cookie: `WorkosCursorSessionToken=${token}`,
|
|
2512
|
+
Accept: "application/json"
|
|
2513
|
+
},
|
|
2514
|
+
signal: AbortSignal.timeout(1e4)
|
|
2515
|
+
});
|
|
2516
|
+
if (!res.ok)
|
|
2517
|
+
return null;
|
|
2518
|
+
return await res.json();
|
|
2519
|
+
} catch {
|
|
2520
|
+
return null;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
async function ingestCursor(db, verbose = false) {
|
|
2524
|
+
const token = getCursorSessionToken();
|
|
2525
|
+
if (!token) {
|
|
2526
|
+
if (verbose)
|
|
2527
|
+
console.log(" cursor: skipped \u2014 set CURSOR_SESSION_TOKEN");
|
|
2528
|
+
return { requests: 0, snapshots: 0 };
|
|
2529
|
+
}
|
|
2530
|
+
const today = new Date().toISOString().substring(0, 10);
|
|
2531
|
+
const prev = getIngestState(db, "cursor", `sync-${today}`);
|
|
2532
|
+
if (prev)
|
|
2533
|
+
return { requests: 0, snapshots: 0 };
|
|
2534
|
+
const machineId = getMachineId();
|
|
2535
|
+
const now = new Date().toISOString();
|
|
2536
|
+
let snapshots = 0;
|
|
2537
|
+
const usage = await cursorFetch("/api/usage", token);
|
|
2538
|
+
if (usage?.premiumRequests != null && usage.maxPremiumRequests) {
|
|
2539
|
+
upsertUsageSnapshot(db, {
|
|
2540
|
+
agent: "cursor",
|
|
2541
|
+
date: today,
|
|
2542
|
+
metric: "premium_requests_used",
|
|
2543
|
+
value: usage.premiumRequests,
|
|
2544
|
+
unit: "count",
|
|
2545
|
+
machine_id: machineId
|
|
2546
|
+
});
|
|
2547
|
+
upsertUsageSnapshot(db, {
|
|
2548
|
+
agent: "cursor",
|
|
2549
|
+
date: today,
|
|
2550
|
+
metric: "premium_requests_limit",
|
|
2551
|
+
value: usage.maxPremiumRequests,
|
|
2552
|
+
unit: "count",
|
|
2553
|
+
machine_id: machineId
|
|
2554
|
+
});
|
|
2555
|
+
snapshots += 2;
|
|
2556
|
+
}
|
|
2557
|
+
const summary = await cursorFetch("/api/usage-summary", token);
|
|
2558
|
+
const onDemand = summary?.individualUsage?.spend ?? summary?.teamUsage?.spend ?? 0;
|
|
2559
|
+
const included = summary?.individualUsage?.includedSpend ?? 0;
|
|
2560
|
+
if (onDemand > 0) {
|
|
2561
|
+
upsertUsageSnapshot(db, {
|
|
2562
|
+
agent: "cursor",
|
|
2563
|
+
date: today,
|
|
2564
|
+
metric: "on_demand_usd",
|
|
2565
|
+
value: onDemand,
|
|
2566
|
+
unit: "usd",
|
|
2567
|
+
machine_id: machineId
|
|
2568
|
+
});
|
|
2569
|
+
snapshots++;
|
|
2570
|
+
}
|
|
2571
|
+
if (included > 0) {
|
|
2572
|
+
upsertUsageSnapshot(db, {
|
|
2573
|
+
agent: "cursor",
|
|
2574
|
+
date: today,
|
|
2575
|
+
metric: "included_consumed_usd",
|
|
2576
|
+
value: included,
|
|
2577
|
+
unit: "usd",
|
|
2578
|
+
machine_id: machineId
|
|
2579
|
+
});
|
|
2580
|
+
snapshots++;
|
|
2581
|
+
}
|
|
2582
|
+
const sessionId = `cursor-${today}-${machineId}`;
|
|
2583
|
+
if (onDemand + included > 0) {
|
|
2584
|
+
upsertSession(db, {
|
|
2585
|
+
id: sessionId,
|
|
2586
|
+
agent: "cursor",
|
|
2587
|
+
project_path: "",
|
|
2588
|
+
project_name: "Cursor subscription",
|
|
2589
|
+
started_at: `${today}T00:00:00.000Z`,
|
|
2590
|
+
ended_at: now,
|
|
2591
|
+
total_cost_usd: onDemand + included,
|
|
2592
|
+
total_tokens: 0,
|
|
2593
|
+
request_count: 1,
|
|
2594
|
+
machine_id: machineId,
|
|
2595
|
+
updated_at: now
|
|
2596
|
+
});
|
|
2597
|
+
upsertRequest(db, {
|
|
2598
|
+
id: `cursor-${today}-${machineId}-usage`,
|
|
2599
|
+
agent: "cursor",
|
|
2600
|
+
session_id: sessionId,
|
|
2601
|
+
model: "cursor-subscription",
|
|
2602
|
+
input_tokens: 0,
|
|
2603
|
+
output_tokens: 0,
|
|
2604
|
+
cache_read_tokens: 0,
|
|
2605
|
+
cache_create_tokens: 0,
|
|
2606
|
+
cost_usd: onDemand + included,
|
|
2607
|
+
cost_basis: "subscription_included",
|
|
2608
|
+
duration_ms: 0,
|
|
2609
|
+
timestamp: now,
|
|
2610
|
+
source_request_id: today,
|
|
2611
|
+
machine_id: machineId,
|
|
2612
|
+
updated_at: now
|
|
2613
|
+
});
|
|
2614
|
+
rollupSession(db, sessionId);
|
|
2615
|
+
}
|
|
2616
|
+
setIngestState(db, "cursor", `sync-${today}`, now);
|
|
2617
|
+
if (verbose)
|
|
2618
|
+
console.log(` cursor: on-demand $${onDemand.toFixed(2)}, included $${included.toFixed(2)}`);
|
|
2619
|
+
return { requests: onDemand + included > 0 ? 1 : 0, snapshots };
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// src/ingest/pi.ts
|
|
2623
|
+
init_database();
|
|
2624
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
|
|
2625
|
+
import { homedir as homedir6 } from "os";
|
|
2626
|
+
import { join as join6 } from "path";
|
|
2627
|
+
var PI_SESSION_DIR = process.env["PI_CODING_AGENT_SESSION_DIR"] ?? join6(homedir6(), ".pi", "agent", "sessions");
|
|
2628
|
+
function walkSessions(dir, acc = []) {
|
|
2629
|
+
if (!existsSync6(dir))
|
|
2630
|
+
return acc;
|
|
2631
|
+
for (const entry of readdirSync5(dir, { withFileTypes: true })) {
|
|
2632
|
+
const p = join6(dir, entry.name);
|
|
2633
|
+
if (entry.isDirectory())
|
|
2634
|
+
walkSessions(p, acc);
|
|
2635
|
+
else if (entry.name.endsWith(".json"))
|
|
2636
|
+
acc.push(p);
|
|
2637
|
+
}
|
|
2638
|
+
return acc;
|
|
2639
|
+
}
|
|
2640
|
+
async function ingestPi(db, verbose = false) {
|
|
2641
|
+
const files = walkSessions(PI_SESSION_DIR);
|
|
2642
|
+
let requests = 0;
|
|
2643
|
+
const touched = new Set;
|
|
2644
|
+
const machineId = getMachineId();
|
|
2645
|
+
const now = new Date().toISOString();
|
|
2646
|
+
for (const file of files) {
|
|
2647
|
+
const mtime = statSync5(file).mtimeMs;
|
|
2648
|
+
const prev = getIngestState(db, "pi", file);
|
|
2649
|
+
if (prev && Number(prev) >= mtime)
|
|
2650
|
+
continue;
|
|
2651
|
+
let data;
|
|
2652
|
+
try {
|
|
2653
|
+
data = JSON.parse(readFileSync5(file, "utf-8"));
|
|
2654
|
+
} catch {
|
|
2655
|
+
continue;
|
|
2656
|
+
}
|
|
2657
|
+
const sessionId = data.id ?? file.replace(/\.json$/, "").split("/").pop() ?? `pi-${statSync5(file).ino}`;
|
|
2658
|
+
const turns = data.turns ?? data.messages?.filter((m) => m.role === "assistant") ?? [];
|
|
2659
|
+
for (let i = 0;i < turns.length; i++) {
|
|
2660
|
+
const turn = turns[i];
|
|
2661
|
+
const usage = turn.usage;
|
|
2662
|
+
if (!usage)
|
|
2663
|
+
continue;
|
|
2664
|
+
const input = usage.input ?? 0;
|
|
2665
|
+
const output = usage.output ?? 0;
|
|
2666
|
+
if (input + output === 0 && !usage.cost)
|
|
2667
|
+
continue;
|
|
2668
|
+
const model = turn.model ?? turn.provider ?? "unknown";
|
|
2669
|
+
const timestamp = turn.timestamp ?? new Date(statSync5(file).mtime).toISOString();
|
|
2670
|
+
const reqId = `pi-${sessionId}-${i}`;
|
|
2671
|
+
upsertRequest(db, {
|
|
2672
|
+
id: reqId,
|
|
2673
|
+
agent: "pi",
|
|
2674
|
+
session_id: sessionId,
|
|
2675
|
+
model,
|
|
2676
|
+
input_tokens: input,
|
|
2677
|
+
output_tokens: output,
|
|
2678
|
+
cache_read_tokens: usage.cacheRead ?? 0,
|
|
2679
|
+
cache_create_tokens: usage.cacheWrite ?? 0,
|
|
2680
|
+
cost_usd: usage.cost ?? 0,
|
|
2681
|
+
cost_basis: defaultCostBasisForAgent("pi"),
|
|
2682
|
+
duration_ms: 0,
|
|
2683
|
+
timestamp,
|
|
2684
|
+
source_request_id: `${sessionId}-${i}`,
|
|
2685
|
+
machine_id: machineId,
|
|
2686
|
+
updated_at: now
|
|
2687
|
+
});
|
|
2688
|
+
requests++;
|
|
2689
|
+
}
|
|
2690
|
+
if (turns.length > 0) {
|
|
2691
|
+
upsertSession(db, {
|
|
2692
|
+
id: sessionId,
|
|
2693
|
+
agent: "pi",
|
|
2694
|
+
project_path: "",
|
|
2695
|
+
project_name: "",
|
|
2696
|
+
started_at: turns[0]?.timestamp ?? now,
|
|
2697
|
+
ended_at: null,
|
|
2698
|
+
total_cost_usd: 0,
|
|
2699
|
+
total_tokens: 0,
|
|
2700
|
+
request_count: 0,
|
|
2701
|
+
machine_id: machineId,
|
|
2702
|
+
updated_at: now
|
|
2703
|
+
});
|
|
2704
|
+
touched.add(sessionId);
|
|
2705
|
+
}
|
|
2706
|
+
setIngestState(db, "pi", file, String(mtime));
|
|
2707
|
+
if (verbose)
|
|
2708
|
+
console.log(` pi: ${sessionId} (${turns.length} turns)`);
|
|
2709
|
+
}
|
|
2710
|
+
for (const sid of touched)
|
|
2711
|
+
rollupSession(db, sid);
|
|
2712
|
+
return { files: files.length, requests, sessions: touched.size };
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// src/ingest/hermes.ts
|
|
2716
|
+
init_database();
|
|
2717
|
+
import { existsSync as existsSync7, statSync as statSync6 } from "fs";
|
|
2718
|
+
import { homedir as homedir7 } from "os";
|
|
2719
|
+
import { join as join7 } from "path";
|
|
2720
|
+
var HERMES_DB = join7(homedir7(), ".hermes", "state.db");
|
|
2721
|
+
function mapCostBasis(billingMode) {
|
|
2722
|
+
if (billingMode === "subscription")
|
|
2723
|
+
return "subscription_included";
|
|
2724
|
+
if (billingMode === "api")
|
|
2725
|
+
return "metered_api";
|
|
2726
|
+
return defaultCostBasisForAgent("hermes");
|
|
2727
|
+
}
|
|
2728
|
+
async function ingestHermes(db, verbose = false) {
|
|
2729
|
+
if (!existsSync7(HERMES_DB)) {
|
|
2730
|
+
return { sessions: 0, requests: 0 };
|
|
2731
|
+
}
|
|
2732
|
+
const { Database: Sqlite } = await import("bun:sqlite");
|
|
2733
|
+
const hermes = new Sqlite(HERMES_DB, { readonly: true });
|
|
2734
|
+
const rows = hermes.prepare(`
|
|
2735
|
+
SELECT id, source, model, started_at, ended_at,
|
|
2736
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
2737
|
+
reasoning_tokens, estimated_cost_usd, actual_cost_usd, billing_mode, parent_session_id
|
|
2738
|
+
FROM sessions
|
|
2739
|
+
ORDER BY started_at DESC
|
|
2740
|
+
`).all();
|
|
2741
|
+
const stateKey = "state.db";
|
|
2742
|
+
const mtime = statSyncSafe(HERMES_DB);
|
|
2743
|
+
const prev = getIngestState(db, "hermes", stateKey);
|
|
2744
|
+
if (prev && Number(prev) >= mtime && rows.length === 0) {
|
|
2745
|
+
hermes.close();
|
|
2746
|
+
return { sessions: 0, requests: 0 };
|
|
2747
|
+
}
|
|
2748
|
+
const machineId = getMachineId();
|
|
2749
|
+
const now = new Date().toISOString();
|
|
2750
|
+
let requests = 0;
|
|
2751
|
+
for (const row of rows) {
|
|
2752
|
+
const sessionId = `hermes-${row.id}`;
|
|
2753
|
+
const startedAt = new Date(row.started_at * 1000).toISOString();
|
|
2754
|
+
const endedAt = row.ended_at ? new Date(row.ended_at * 1000).toISOString() : null;
|
|
2755
|
+
const cost = row.actual_cost_usd ?? row.estimated_cost_usd ?? 0;
|
|
2756
|
+
const tokens = row.input_tokens + row.output_tokens + row.cache_read_tokens + row.cache_write_tokens + row.reasoning_tokens;
|
|
2757
|
+
upsertSession(db, {
|
|
2758
|
+
id: sessionId,
|
|
2759
|
+
agent: "hermes",
|
|
2760
|
+
project_path: row.source ?? "",
|
|
2761
|
+
project_name: row.source ?? "",
|
|
2762
|
+
started_at: startedAt,
|
|
2763
|
+
ended_at: endedAt,
|
|
2764
|
+
total_cost_usd: cost,
|
|
2765
|
+
total_tokens: tokens,
|
|
2766
|
+
request_count: 1,
|
|
2767
|
+
machine_id: machineId,
|
|
2768
|
+
updated_at: now
|
|
2769
|
+
});
|
|
2770
|
+
const reqId = `hermes-${row.id}-rollup`;
|
|
2771
|
+
upsertRequest(db, {
|
|
2772
|
+
id: reqId,
|
|
2773
|
+
agent: "hermes",
|
|
2774
|
+
session_id: sessionId,
|
|
2775
|
+
model: row.model ?? "unknown",
|
|
2776
|
+
input_tokens: row.input_tokens,
|
|
2777
|
+
output_tokens: row.output_tokens + row.reasoning_tokens,
|
|
2778
|
+
cache_read_tokens: row.cache_read_tokens,
|
|
2779
|
+
cache_create_tokens: row.cache_write_tokens,
|
|
2780
|
+
cost_usd: cost,
|
|
2781
|
+
cost_basis: mapCostBasis(row.billing_mode),
|
|
2782
|
+
duration_ms: 0,
|
|
2783
|
+
timestamp: endedAt ?? startedAt,
|
|
2784
|
+
source_request_id: row.id,
|
|
2785
|
+
machine_id: machineId,
|
|
2786
|
+
updated_at: now
|
|
2787
|
+
});
|
|
2788
|
+
requests++;
|
|
2789
|
+
rollupSession(db, sessionId);
|
|
2790
|
+
if (verbose)
|
|
2791
|
+
console.log(` hermes: ${sessionId} $${cost.toFixed(4)}`);
|
|
2792
|
+
}
|
|
2793
|
+
setIngestState(db, "hermes", stateKey, String(mtime));
|
|
2794
|
+
hermes.close();
|
|
2795
|
+
return { sessions: rows.length, requests };
|
|
2796
|
+
}
|
|
2797
|
+
function statSyncSafe(path) {
|
|
2798
|
+
try {
|
|
2799
|
+
return statSync6(path).mtimeMs;
|
|
2800
|
+
} catch {
|
|
2801
|
+
return 0;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// src/ingest/claude-quota.ts
|
|
2806
|
+
init_database();
|
|
2807
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
2808
|
+
|
|
2809
|
+
// src/lib/paths.ts
|
|
2810
|
+
import { homedir as homedir8 } from "os";
|
|
2811
|
+
import { join as join8 } from "path";
|
|
2812
|
+
function getHomeDir() {
|
|
2813
|
+
return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir8();
|
|
2814
|
+
}
|
|
2815
|
+
function agentPaths() {
|
|
2816
|
+
const home = getHomeDir();
|
|
2817
|
+
return {
|
|
2818
|
+
claudeProjects: join8(home, ".claude", "projects"),
|
|
2819
|
+
claudeCredentials: join8(home, ".claude", ".credentials.json"),
|
|
2820
|
+
takumiProjects: join8(home, ".takumi", "projects"),
|
|
2821
|
+
codexDir: join8(home, ".codex"),
|
|
2822
|
+
codexDb: join8(home, ".codex", "state_5.sqlite"),
|
|
2823
|
+
codexAuth: join8(home, ".codex", "auth.json"),
|
|
2824
|
+
codexConfig: join8(home, ".codex", "config.toml"),
|
|
2825
|
+
geminiTmp: join8(home, ".gemini", "tmp"),
|
|
2826
|
+
geminiHistory: join8(home, ".gemini", "history"),
|
|
2827
|
+
opencodeMessages: join8(home, ".local", "share", "opencode", "storage", "message"),
|
|
2828
|
+
piSessions: join8(home, ".pi", "agent", "sessions"),
|
|
2829
|
+
hermesDir: join8(home, ".hermes"),
|
|
2830
|
+
hermesDb: join8(home, ".hermes", "state.db")
|
|
2831
|
+
};
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// src/ingest/claude-quota.ts
|
|
2835
|
+
var CREDENTIALS_PATH = agentPaths().claudeCredentials;
|
|
2836
|
+
var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
2837
|
+
var OAUTH_BETA = "oauth-2025-04-20";
|
|
2838
|
+
function readClaudeToken() {
|
|
2839
|
+
const fromEnv = process.env["CLAUDE_OAUTH_TOKEN"] ?? process.env["ANTHROPIC_OAUTH_TOKEN"];
|
|
2840
|
+
if (fromEnv)
|
|
2841
|
+
return { token: fromEnv };
|
|
2842
|
+
if (!existsSync8(CREDENTIALS_PATH))
|
|
2843
|
+
return null;
|
|
2844
|
+
try {
|
|
2845
|
+
const creds = JSON.parse(readFileSync7(CREDENTIALS_PATH, "utf-8"));
|
|
2846
|
+
const oauth = creds.claudeAiOauth;
|
|
2847
|
+
if (!oauth?.accessToken)
|
|
2848
|
+
return null;
|
|
2849
|
+
return {
|
|
2850
|
+
token: oauth.accessToken,
|
|
2851
|
+
subscriptionType: oauth.subscriptionType,
|
|
2852
|
+
rateLimitTier: oauth.rateLimitTier
|
|
2853
|
+
};
|
|
2854
|
+
} catch {
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
function inferMonthlyFee(subscriptionType, rateLimitTier) {
|
|
2859
|
+
const tier = `${subscriptionType ?? ""} ${rateLimitTier ?? ""}`.toLowerCase();
|
|
2860
|
+
if (tier.includes("max") && tier.includes("20"))
|
|
2861
|
+
return 200;
|
|
2862
|
+
if (tier.includes("max"))
|
|
2863
|
+
return 100;
|
|
2864
|
+
if (tier.includes("pro"))
|
|
2865
|
+
return 20;
|
|
2866
|
+
if (tier.includes("team"))
|
|
2867
|
+
return 30;
|
|
2868
|
+
return 20;
|
|
2869
|
+
}
|
|
2870
|
+
async function fetchClaudeOAuthUsage(token) {
|
|
2871
|
+
try {
|
|
2872
|
+
const res = await fetch(USAGE_URL, {
|
|
2873
|
+
headers: {
|
|
2874
|
+
Authorization: `Bearer ${token}`,
|
|
2875
|
+
"anthropic-beta": OAUTH_BETA,
|
|
2876
|
+
Accept: "application/json"
|
|
2877
|
+
},
|
|
2878
|
+
signal: AbortSignal.timeout(1e4)
|
|
2879
|
+
});
|
|
2880
|
+
if (!res.ok)
|
|
2881
|
+
return null;
|
|
2882
|
+
return await res.json();
|
|
2883
|
+
} catch {
|
|
2884
|
+
return null;
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
async function ingestClaudeQuota(db, verbose = false) {
|
|
2888
|
+
const auth = readClaudeToken();
|
|
2889
|
+
if (!auth) {
|
|
2890
|
+
if (verbose)
|
|
2891
|
+
console.log(" claude quota: skipped \u2014 no OAuth token (~/.claude/.credentials.json or CLAUDE_OAUTH_TOKEN)");
|
|
2892
|
+
return { snapshots: 0 };
|
|
2893
|
+
}
|
|
2894
|
+
const today = new Date().toISOString().substring(0, 10);
|
|
2895
|
+
const prev = getIngestState(db, "claude", `quota-${today}`);
|
|
2896
|
+
if (prev)
|
|
2897
|
+
return { snapshots: 0 };
|
|
2898
|
+
const usage = await fetchClaudeOAuthUsage(auth.token);
|
|
2899
|
+
if (!usage) {
|
|
2900
|
+
if (verbose)
|
|
2901
|
+
console.log(" claude quota: OAuth usage endpoint unavailable");
|
|
2902
|
+
return { snapshots: 0 };
|
|
2903
|
+
}
|
|
2904
|
+
const machineId = getMachineId();
|
|
2905
|
+
let snapshots = 0;
|
|
2906
|
+
const windows = [
|
|
2907
|
+
["five_hour_utilization", usage.five_hour],
|
|
2908
|
+
["seven_day_utilization", usage.seven_day],
|
|
2909
|
+
["seven_day_sonnet_utilization", usage.seven_day_sonnet],
|
|
2910
|
+
["seven_day_opus_utilization", usage.seven_day_opus]
|
|
2911
|
+
];
|
|
2912
|
+
for (const [metric, window] of windows) {
|
|
2913
|
+
if (window?.utilization == null)
|
|
2914
|
+
continue;
|
|
2915
|
+
upsertUsageSnapshot(db, {
|
|
2916
|
+
agent: "claude",
|
|
2917
|
+
date: today,
|
|
2918
|
+
metric,
|
|
2919
|
+
value: Math.round(window.utilization * 1000) / 10,
|
|
2920
|
+
unit: "percent",
|
|
2921
|
+
machine_id: machineId
|
|
2922
|
+
});
|
|
2923
|
+
snapshots++;
|
|
2924
|
+
if (window.resets_at) {
|
|
2925
|
+
upsertUsageSnapshot(db, {
|
|
2926
|
+
agent: "claude",
|
|
2927
|
+
date: today,
|
|
2928
|
+
metric: `${metric}_resets_at`,
|
|
2929
|
+
value: Date.parse(window.resets_at),
|
|
2930
|
+
unit: "epoch_ms",
|
|
2931
|
+
machine_id: machineId
|
|
2932
|
+
});
|
|
2933
|
+
snapshots++;
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
if (usage.extra_usage?.spend != null) {
|
|
2937
|
+
upsertUsageSnapshot(db, {
|
|
2938
|
+
agent: "claude",
|
|
2939
|
+
date: today,
|
|
2940
|
+
metric: "on_demand_usd",
|
|
2941
|
+
value: usage.extra_usage.spend,
|
|
2942
|
+
unit: "usd",
|
|
2943
|
+
machine_id: machineId
|
|
2944
|
+
});
|
|
2945
|
+
snapshots++;
|
|
2946
|
+
}
|
|
2947
|
+
if (usage.extra_usage?.limit != null) {
|
|
2948
|
+
upsertUsageSnapshot(db, {
|
|
2949
|
+
agent: "claude",
|
|
2950
|
+
date: today,
|
|
2951
|
+
metric: "on_demand_limit_usd",
|
|
2952
|
+
value: usage.extra_usage.limit,
|
|
2953
|
+
unit: "usd",
|
|
2954
|
+
machine_id: machineId
|
|
2955
|
+
});
|
|
2956
|
+
snapshots++;
|
|
2957
|
+
}
|
|
2958
|
+
const monthlyFee = inferMonthlyFee(auth.subscriptionType, auth.rateLimitTier);
|
|
2959
|
+
const now = new Date().toISOString();
|
|
2960
|
+
upsertSubscription(db, {
|
|
2961
|
+
id: "anthropic-claude-oauth",
|
|
2962
|
+
provider: "anthropic",
|
|
2963
|
+
agent: "claude",
|
|
2964
|
+
plan: auth.rateLimitTier ?? auth.subscriptionType ?? "claude_pro",
|
|
2965
|
+
monthly_fee_usd: monthlyFee,
|
|
2966
|
+
included_usage_usd: monthlyFee,
|
|
2967
|
+
billing_cycle_start: null,
|
|
2968
|
+
reset_policy: "monthly",
|
|
2969
|
+
active: 1,
|
|
2970
|
+
created_at: now,
|
|
2971
|
+
updated_at: now
|
|
2972
|
+
});
|
|
2973
|
+
setIngestState(db, "claude", `quota-${today}`, new Date().toISOString());
|
|
2974
|
+
if (verbose)
|
|
2975
|
+
console.log(` claude quota: ${snapshots} snapshots`);
|
|
2976
|
+
return { snapshots };
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// src/ingest/codex-quota.ts
|
|
2980
|
+
init_database();
|
|
2981
|
+
import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
|
|
2982
|
+
var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
|
|
2983
|
+
function readCodexAuth() {
|
|
2984
|
+
const fromEnv = process.env["CODEX_OAUTH_TOKEN"];
|
|
2985
|
+
if (fromEnv)
|
|
2986
|
+
return { token: fromEnv, authMode: "chatgpt" };
|
|
2987
|
+
const authPath = agentPaths().codexAuth;
|
|
2988
|
+
if (!existsSync9(authPath))
|
|
2989
|
+
return null;
|
|
2990
|
+
try {
|
|
2991
|
+
const auth = JSON.parse(readFileSync8(authPath, "utf-8"));
|
|
2992
|
+
const token = auth.tokens?.access_token;
|
|
2993
|
+
if (!token)
|
|
2994
|
+
return null;
|
|
2995
|
+
return {
|
|
2996
|
+
token,
|
|
2997
|
+
accountId: auth.tokens?.account_id,
|
|
2998
|
+
authMode: auth.auth_mode ?? "chatgpt"
|
|
2999
|
+
};
|
|
3000
|
+
} catch {
|
|
3001
|
+
return null;
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
function planMonthlyFee(planType) {
|
|
3005
|
+
const plan = (planType ?? "").toLowerCase();
|
|
3006
|
+
if (plan.includes("pro"))
|
|
3007
|
+
return 200;
|
|
3008
|
+
if (plan.includes("plus"))
|
|
3009
|
+
return 20;
|
|
3010
|
+
if (plan.includes("team"))
|
|
3011
|
+
return 30;
|
|
3012
|
+
return 20;
|
|
3013
|
+
}
|
|
3014
|
+
async function fetchCodexUsage(token, accountId) {
|
|
3015
|
+
try {
|
|
3016
|
+
const headers = {
|
|
3017
|
+
Authorization: `Bearer ${token}`,
|
|
3018
|
+
Accept: "application/json",
|
|
3019
|
+
"User-Agent": "economy-cli"
|
|
3020
|
+
};
|
|
3021
|
+
if (accountId)
|
|
3022
|
+
headers["ChatGPT-Account-Id"] = accountId;
|
|
3023
|
+
const res = await fetch(WHAM_USAGE_URL, {
|
|
3024
|
+
headers,
|
|
3025
|
+
signal: AbortSignal.timeout(1e4)
|
|
3026
|
+
});
|
|
3027
|
+
if (!res.ok)
|
|
3028
|
+
return null;
|
|
3029
|
+
return await res.json();
|
|
3030
|
+
} catch {
|
|
3031
|
+
return null;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
async function ingestCodexQuota(db, verbose = false) {
|
|
3035
|
+
const auth = readCodexAuth();
|
|
3036
|
+
if (!auth) {
|
|
3037
|
+
if (verbose)
|
|
3038
|
+
console.log(" codex quota: skipped \u2014 no ~/.codex/auth.json or CODEX_OAUTH_TOKEN");
|
|
3039
|
+
return { snapshots: 0 };
|
|
3040
|
+
}
|
|
3041
|
+
if (auth.authMode === "api_key" || auth.authMode === "api") {
|
|
3042
|
+
if (verbose)
|
|
3043
|
+
console.log(" codex quota: skipped \u2014 API key mode (no subscription quota)");
|
|
3044
|
+
return { snapshots: 0 };
|
|
3045
|
+
}
|
|
3046
|
+
const today = new Date().toISOString().substring(0, 10);
|
|
3047
|
+
const prev = getIngestState(db, "codex", `quota-${today}`);
|
|
3048
|
+
if (prev)
|
|
3049
|
+
return { snapshots: 0 };
|
|
3050
|
+
const usage = await fetchCodexUsage(auth.token, auth.accountId);
|
|
3051
|
+
if (!usage) {
|
|
3052
|
+
if (verbose)
|
|
3053
|
+
console.log(" codex quota: wham/usage endpoint unavailable");
|
|
3054
|
+
return { snapshots: 0 };
|
|
3055
|
+
}
|
|
3056
|
+
const machineId = getMachineId();
|
|
3057
|
+
let snapshots = 0;
|
|
3058
|
+
const now = new Date().toISOString();
|
|
3059
|
+
const windows = [
|
|
3060
|
+
["five_hour_utilization", usage.rate_limit?.primary_window],
|
|
3061
|
+
["seven_day_utilization", usage.rate_limit?.secondary_window]
|
|
3062
|
+
];
|
|
3063
|
+
for (const [metric, window] of windows) {
|
|
3064
|
+
if (window?.used_percent == null)
|
|
3065
|
+
continue;
|
|
3066
|
+
upsertUsageSnapshot(db, {
|
|
3067
|
+
agent: "codex",
|
|
3068
|
+
date: today,
|
|
3069
|
+
metric,
|
|
3070
|
+
value: window.used_percent,
|
|
3071
|
+
unit: "percent",
|
|
3072
|
+
machine_id: machineId
|
|
3073
|
+
});
|
|
3074
|
+
snapshots++;
|
|
3075
|
+
if (window.reset_at) {
|
|
3076
|
+
upsertUsageSnapshot(db, {
|
|
3077
|
+
agent: "codex",
|
|
3078
|
+
date: today,
|
|
3079
|
+
metric: `${metric}_resets_at`,
|
|
3080
|
+
value: window.reset_at * 1000,
|
|
3081
|
+
unit: "epoch_ms",
|
|
3082
|
+
machine_id: machineId
|
|
3083
|
+
});
|
|
3084
|
+
snapshots++;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
if (usage.credits?.balance != null) {
|
|
3088
|
+
upsertUsageSnapshot(db, {
|
|
3089
|
+
agent: "codex",
|
|
3090
|
+
date: today,
|
|
3091
|
+
metric: "credits_balance_usd",
|
|
3092
|
+
value: usage.credits.balance,
|
|
3093
|
+
unit: "usd",
|
|
3094
|
+
machine_id: machineId
|
|
3095
|
+
});
|
|
3096
|
+
snapshots++;
|
|
3097
|
+
}
|
|
3098
|
+
const monthlyFee = planMonthlyFee(usage.plan_type);
|
|
3099
|
+
upsertSubscription(db, {
|
|
3100
|
+
id: "openai-codex-oauth",
|
|
3101
|
+
provider: "openai",
|
|
3102
|
+
agent: "codex",
|
|
3103
|
+
plan: usage.plan_type ?? "chatgpt_plus",
|
|
3104
|
+
monthly_fee_usd: monthlyFee,
|
|
3105
|
+
included_usage_usd: monthlyFee,
|
|
3106
|
+
billing_cycle_start: null,
|
|
3107
|
+
reset_policy: "monthly",
|
|
3108
|
+
active: 1,
|
|
3109
|
+
created_at: now,
|
|
3110
|
+
updated_at: now
|
|
3111
|
+
});
|
|
3112
|
+
setIngestState(db, "codex", `quota-${today}`, now);
|
|
3113
|
+
if (verbose)
|
|
3114
|
+
console.log(` codex quota: ${snapshots} snapshots (${usage.plan_type ?? "unknown plan"})`);
|
|
3115
|
+
return { snapshots };
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// src/lib/sync-all.ts
|
|
3119
|
+
init_database();
|
|
3120
|
+
|
|
3121
|
+
// src/lib/cloud-sync.ts
|
|
3122
|
+
init_database();
|
|
3123
|
+
|
|
3124
|
+
// src/lib/package-metadata.ts
|
|
3125
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
3126
|
+
var cachedMetadata = null;
|
|
3127
|
+
function getPackageMetadata() {
|
|
3128
|
+
if (cachedMetadata)
|
|
3129
|
+
return cachedMetadata;
|
|
3130
|
+
const raw = readFileSync9(new URL("../../package.json", import.meta.url), "utf8");
|
|
3131
|
+
const parsed = JSON.parse(raw);
|
|
3132
|
+
cachedMetadata = {
|
|
3133
|
+
name: parsed.name ?? "@hasna/economy",
|
|
3134
|
+
version: parsed.version ?? "0.0.0"
|
|
3135
|
+
};
|
|
3136
|
+
return cachedMetadata;
|
|
3137
|
+
}
|
|
3138
|
+
var packageMetadata = getPackageMetadata();
|
|
3139
|
+
|
|
3140
|
+
// src/lib/cloud-sync.ts
|
|
3141
|
+
var CLOUD_TABLES = [
|
|
3142
|
+
"requests",
|
|
3143
|
+
"sessions",
|
|
3144
|
+
"projects",
|
|
3145
|
+
"budgets",
|
|
3146
|
+
"goals",
|
|
3147
|
+
"model_pricing",
|
|
3148
|
+
"billing_daily",
|
|
3149
|
+
"subscriptions",
|
|
3150
|
+
"usage_snapshots",
|
|
3151
|
+
"savings_daily",
|
|
3152
|
+
"machines",
|
|
3153
|
+
"ingest_state"
|
|
3154
|
+
];
|
|
3155
|
+
function getCloudDatabaseUrl() {
|
|
3156
|
+
return process.env["ECONOMY_CLOUD_DATABASE_URL"] ?? process.env["HASNA_ECONOMY_CLOUD_DATABASE_URL"] ?? null;
|
|
3157
|
+
}
|
|
3158
|
+
function isCloudAutoEnabled() {
|
|
3159
|
+
return process.env["ECONOMY_CLOUD_AUTO"] === "1" || process.env["ECONOMY_CLOUD_AUTO"] === "true";
|
|
3160
|
+
}
|
|
3161
|
+
function getCloudPullIntervalMinutes() {
|
|
3162
|
+
const raw = process.env["ECONOMY_CLOUD_PULL_INTERVAL"];
|
|
3163
|
+
if (!raw)
|
|
3164
|
+
return 15;
|
|
3165
|
+
const n = Number(raw);
|
|
3166
|
+
return Number.isFinite(n) && n > 0 ? n : 15;
|
|
3167
|
+
}
|
|
3168
|
+
async function getCloudPg() {
|
|
3169
|
+
const url = getCloudDatabaseUrl();
|
|
3170
|
+
if (!url) {
|
|
3171
|
+
throw new Error("Missing ECONOMY_CLOUD_DATABASE_URL (or HASNA_ECONOMY_CLOUD_DATABASE_URL)");
|
|
3172
|
+
}
|
|
3173
|
+
const { PgAdapterAsync } = await import("@hasna/cloud");
|
|
3174
|
+
return new PgAdapterAsync(url);
|
|
3175
|
+
}
|
|
3176
|
+
async function runCloudMigrations(cloud) {
|
|
3177
|
+
const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
|
|
3178
|
+
for (const sql of PG_MIGRATIONS2) {
|
|
3179
|
+
await cloud.run(sql);
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
function isCloudIncrementalEnabled() {
|
|
3183
|
+
return process.env["ECONOMY_CLOUD_INCREMENTAL"] === "1" || process.env["ECONOMY_CLOUD_INCREMENTAL"] === "true";
|
|
3184
|
+
}
|
|
3185
|
+
async function cloudPush(opts) {
|
|
3186
|
+
const { syncPush, incrementalSyncPush, ensureSyncMetaTable, SqliteAdapter } = await import("@hasna/cloud");
|
|
3187
|
+
const cloud = await getCloudPg();
|
|
3188
|
+
const local = new SqliteAdapter(getDbPath());
|
|
3189
|
+
await runCloudMigrations(cloud);
|
|
3190
|
+
const tables = opts?.tables ?? [...CLOUD_TABLES];
|
|
3191
|
+
let rows = 0;
|
|
3192
|
+
if (isCloudIncrementalEnabled()) {
|
|
3193
|
+
ensureSyncMetaTable(local);
|
|
3194
|
+
const results = incrementalSyncPush(local, cloud, tables, { conflictColumn: "updated_at" });
|
|
3195
|
+
rows = results.reduce((s, r) => s + r.synced_rows, 0);
|
|
3196
|
+
} else {
|
|
3197
|
+
const results = await syncPush(local, cloud, { tables, conflictColumn: "updated_at" });
|
|
3198
|
+
rows = results.reduce((s, r) => s + r.rowsWritten, 0);
|
|
3199
|
+
}
|
|
3200
|
+
touchMachineRegistry(local, "push");
|
|
3201
|
+
local.close();
|
|
3202
|
+
await cloud.close();
|
|
3203
|
+
return { rows, machine: getMachineId() };
|
|
3204
|
+
}
|
|
3205
|
+
async function cloudPull(opts) {
|
|
3206
|
+
const { syncPull, incrementalSyncPull, ensureSyncMetaTable, SqliteAdapter } = await import("@hasna/cloud");
|
|
3207
|
+
const cloud = await getCloudPg();
|
|
3208
|
+
const local = new SqliteAdapter(getDbPath());
|
|
3209
|
+
await runCloudMigrations(cloud);
|
|
3210
|
+
const tables = opts?.tables ?? [...CLOUD_TABLES];
|
|
3211
|
+
let rows = 0;
|
|
3212
|
+
if (isCloudIncrementalEnabled()) {
|
|
3213
|
+
ensureSyncMetaTable(local);
|
|
3214
|
+
const results = incrementalSyncPull(cloud, local, tables, { conflictColumn: "updated_at" });
|
|
3215
|
+
rows = results.reduce((s, r) => s + r.synced_rows, 0);
|
|
3216
|
+
} else {
|
|
3217
|
+
const results = await syncPull(cloud, local, { tables, conflictColumn: "updated_at" });
|
|
3218
|
+
rows = results.reduce((s, r) => s + r.rowsWritten, 0);
|
|
3219
|
+
}
|
|
3220
|
+
touchMachineRegistry(local, "pull");
|
|
3221
|
+
local.close();
|
|
3222
|
+
await cloud.close();
|
|
3223
|
+
setLastCloudPull();
|
|
3224
|
+
return { rows, machine: getMachineId() };
|
|
3225
|
+
}
|
|
3226
|
+
function setLastCloudPull(at = new Date().toISOString()) {
|
|
3227
|
+
const db = openDatabase();
|
|
3228
|
+
db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES ('cloud', 'last_pull_at', ?)`).run(at);
|
|
3229
|
+
}
|
|
3230
|
+
function getLastCloudPull() {
|
|
3231
|
+
const db = openDatabase();
|
|
3232
|
+
const row = db.prepare(`SELECT value FROM ingest_state WHERE source = 'cloud' AND key = 'last_pull_at'`).get();
|
|
3233
|
+
return row?.value ?? null;
|
|
3234
|
+
}
|
|
3235
|
+
function shouldPullFromCloud() {
|
|
3236
|
+
if (!getCloudDatabaseUrl())
|
|
3237
|
+
return false;
|
|
3238
|
+
const last = getLastCloudPull();
|
|
3239
|
+
if (!last)
|
|
3240
|
+
return true;
|
|
3241
|
+
const ageMs = Date.now() - new Date(last).getTime();
|
|
3242
|
+
return ageMs > getCloudPullIntervalMinutes() * 60000;
|
|
3243
|
+
}
|
|
3244
|
+
async function maybePullFromCloud() {
|
|
3245
|
+
if (!shouldPullFromCloud())
|
|
3246
|
+
return false;
|
|
3247
|
+
try {
|
|
3248
|
+
await cloudPull();
|
|
3249
|
+
return true;
|
|
3250
|
+
} catch {
|
|
3251
|
+
return false;
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
async function maybePushAfterIngest() {
|
|
3255
|
+
if (!isCloudAutoEnabled() || !getCloudDatabaseUrl())
|
|
3256
|
+
return false;
|
|
3257
|
+
try {
|
|
3258
|
+
await cloudPush();
|
|
3259
|
+
return true;
|
|
3260
|
+
} catch {
|
|
3261
|
+
return false;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
function touchMachineRegistry(db, direction) {
|
|
3265
|
+
const now = new Date().toISOString();
|
|
3266
|
+
const machine = getMachineId();
|
|
3267
|
+
db.prepare(`
|
|
3268
|
+
INSERT INTO machines (machine_id, hostname, last_seen_at, last_push_at, last_pull_at, economy_version, updated_at)
|
|
3269
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3270
|
+
ON CONFLICT(machine_id) DO UPDATE SET
|
|
3271
|
+
hostname = excluded.hostname,
|
|
3272
|
+
last_seen_at = excluded.last_seen_at,
|
|
3273
|
+
last_push_at = CASE WHEN ? = 'push' THEN excluded.last_push_at ELSE machines.last_push_at END,
|
|
3274
|
+
last_pull_at = CASE WHEN ? = 'pull' THEN excluded.last_pull_at ELSE machines.last_pull_at END,
|
|
3275
|
+
economy_version = excluded.economy_version,
|
|
3276
|
+
updated_at = excluded.updated_at
|
|
3277
|
+
`).run(machine, machine, now, direction === "push" ? now : null, direction === "pull" ? now : null, packageMetadata.version, now, direction, direction);
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
// src/lib/sync-all.ts
|
|
3281
|
+
async function syncAll(db, opts = {}) {
|
|
3282
|
+
const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes);
|
|
3283
|
+
const all = !anySpecific;
|
|
3284
|
+
await maybePullFromCloud();
|
|
3285
|
+
const result = { deduped: 0, cloudPulled: false, cloudPushed: false };
|
|
3286
|
+
if (all || opts.claude) {
|
|
3287
|
+
result.claude = await ingestClaude(db, opts.verbose);
|
|
3288
|
+
result.claudeQuota = await ingestClaudeQuota(db, opts.verbose);
|
|
3289
|
+
}
|
|
3290
|
+
if (all || opts.takumi)
|
|
3291
|
+
result.takumi = await ingestTakumi(db, opts.verbose);
|
|
3292
|
+
if (all || opts.codex) {
|
|
3293
|
+
result.codex = await ingestCodex(db, opts.verbose);
|
|
3294
|
+
result.codexQuota = await ingestCodexQuota(db, opts.verbose);
|
|
3295
|
+
}
|
|
3296
|
+
if (all || opts.gemini)
|
|
3297
|
+
result.gemini = await ingestGemini(db, opts.verbose);
|
|
3298
|
+
if (all || opts.opencode)
|
|
3299
|
+
result.opencode = await ingestOpenCode(db, opts.verbose);
|
|
3300
|
+
if (all || opts.cursor)
|
|
3301
|
+
result.cursor = await ingestCursor(db, opts.verbose);
|
|
3302
|
+
if (all || opts.pi)
|
|
3303
|
+
result.pi = await ingestPi(db, opts.verbose);
|
|
3304
|
+
if (all || opts.hermes)
|
|
3305
|
+
result.hermes = await ingestHermes(db, opts.verbose);
|
|
3306
|
+
result.deduped = dedupeRequests(db);
|
|
3307
|
+
result.cloudPushed = await maybePushAfterIngest();
|
|
3308
|
+
return result;
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// src/lib/billing-diff.ts
|
|
3312
|
+
init_database();
|
|
3313
|
+
var PROVIDER_TO_AGENT = {
|
|
3314
|
+
anthropic: "claude",
|
|
3315
|
+
openai: "codex",
|
|
3316
|
+
gemini: "gemini",
|
|
3317
|
+
google: "gemini"
|
|
3318
|
+
};
|
|
3319
|
+
function queryBillingDiff(db, period, thresholdPct = 15) {
|
|
3320
|
+
const estimated = querySummary(db, period, undefined, true);
|
|
3321
|
+
const actual = queryBillingSummary(db, period);
|
|
3322
|
+
const delta = estimated.total_usd - actual.total_usd;
|
|
3323
|
+
const deltaPct = actual.total_usd > 0 ? delta / actual.total_usd * 100 : 0;
|
|
3324
|
+
const agentRows = db.prepare(`
|
|
3325
|
+
SELECT agent, COALESCE(SUM(cost_usd), 0) as estimated_usd
|
|
3326
|
+
FROM requests
|
|
3327
|
+
WHERE ${periodWhere3(period, "timestamp")}
|
|
3328
|
+
GROUP BY agent
|
|
3329
|
+
`).all();
|
|
3330
|
+
const by_agent = agentRows.map((row) => {
|
|
3331
|
+
const provider = Object.entries(PROVIDER_TO_AGENT).find(([, a]) => a === row.agent)?.[0];
|
|
3332
|
+
const actualUsd = provider ? actual.by_provider[provider] ?? 0 : 0;
|
|
3333
|
+
const rowDelta = row.estimated_usd - actualUsd;
|
|
3334
|
+
const rowPct = actualUsd > 0 ? rowDelta / actualUsd * 100 : 0;
|
|
3335
|
+
return {
|
|
3336
|
+
agent: row.agent,
|
|
3337
|
+
estimated_usd: row.estimated_usd,
|
|
3338
|
+
actual_usd: actualUsd,
|
|
3339
|
+
delta_usd: rowDelta,
|
|
3340
|
+
delta_pct: rowPct
|
|
3341
|
+
};
|
|
3342
|
+
}).sort((a, b) => Math.abs(b.delta_usd) - Math.abs(a.delta_usd));
|
|
3343
|
+
return {
|
|
3344
|
+
period,
|
|
3345
|
+
estimated_usd: estimated.total_usd,
|
|
3346
|
+
actual_usd: actual.total_usd,
|
|
3347
|
+
delta_usd: delta,
|
|
3348
|
+
delta_pct: deltaPct,
|
|
3349
|
+
threshold_pct: thresholdPct,
|
|
3350
|
+
is_alert: Math.abs(deltaPct) > thresholdPct,
|
|
3351
|
+
by_agent,
|
|
3352
|
+
by_provider: actual.by_provider
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
function periodWhere3(period, column) {
|
|
3356
|
+
switch (period) {
|
|
3357
|
+
case "today":
|
|
3358
|
+
return `DATE(${column}) = DATE('now')`;
|
|
3359
|
+
case "yesterday":
|
|
3360
|
+
return `DATE(${column}) = DATE('now', '-1 day')`;
|
|
3361
|
+
case "week":
|
|
3362
|
+
return `${column} >= DATE('now', 'weekday 0', '-7 days')`;
|
|
3363
|
+
case "month":
|
|
3364
|
+
return `${column} >= DATE('now', 'start of month')`;
|
|
3365
|
+
case "year":
|
|
3366
|
+
return `${column} >= DATE('now', 'start of year')`;
|
|
3367
|
+
case "all":
|
|
3368
|
+
return "1=1";
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
// src/server/serve.ts
|
|
3373
|
+
init_database();
|
|
3374
|
+
|
|
3375
|
+
// src/lib/serve-auth.ts
|
|
3376
|
+
function getServeApiToken() {
|
|
3377
|
+
return process.env["ECONOMY_API_TOKEN"] ?? process.env["HASNA_ECONOMY_API_TOKEN"];
|
|
3378
|
+
}
|
|
3379
|
+
function getServeBindHost() {
|
|
3380
|
+
const explicit = process.env["ECONOMY_BIND"] ?? process.env["ECONOMY_HOST"];
|
|
3381
|
+
if (explicit)
|
|
3382
|
+
return explicit;
|
|
3383
|
+
return getServeApiToken() ? "127.0.0.1" : "0.0.0.0";
|
|
3384
|
+
}
|
|
3385
|
+
function isAuthorizedRequest(req, path) {
|
|
3386
|
+
const token = getServeApiToken();
|
|
3387
|
+
if (!token)
|
|
3388
|
+
return true;
|
|
3389
|
+
if (path === "/health")
|
|
3390
|
+
return true;
|
|
3391
|
+
const auth = req.headers.get("Authorization");
|
|
3392
|
+
if (auth === `Bearer ${token}`)
|
|
3393
|
+
return true;
|
|
3394
|
+
if (req.headers.get("X-Economy-Token") === token)
|
|
3395
|
+
return true;
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
// src/server/serve.ts
|
|
731
3400
|
import { randomUUID } from "crypto";
|
|
3401
|
+
import { existsSync as existsSync11 } from "fs";
|
|
3402
|
+
import { resolve, sep } from "path";
|
|
732
3403
|
var CORS = {
|
|
733
3404
|
"Access-Control-Allow-Origin": "*",
|
|
734
3405
|
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
|
735
|
-
"Access-Control-Allow-Headers": "Content-Type"
|
|
3406
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Economy-Token"
|
|
736
3407
|
};
|
|
3408
|
+
var AGENT_ERROR = `agent must be one of: ${AGENTS.join(", ")}`;
|
|
3409
|
+
var SYNC_SOURCES = ["all", ...AGENTS];
|
|
3410
|
+
var DEFAULT_DASHBOARD_DIR = new URL("../../dashboard/dist", import.meta.url).pathname;
|
|
737
3411
|
function json(data, status = 200) {
|
|
738
3412
|
return new Response(JSON.stringify(data), {
|
|
739
3413
|
status,
|
|
@@ -746,6 +3420,70 @@ function ok(data, meta) {
|
|
|
746
3420
|
function err(message, status = 400) {
|
|
747
3421
|
return json({ error: message }, status);
|
|
748
3422
|
}
|
|
3423
|
+
function normalizeBudgetPeriod(value) {
|
|
3424
|
+
switch (value) {
|
|
3425
|
+
case "day":
|
|
3426
|
+
case "daily":
|
|
3427
|
+
return "daily";
|
|
3428
|
+
case "week":
|
|
3429
|
+
case "weekly":
|
|
3430
|
+
return "weekly";
|
|
3431
|
+
case "month":
|
|
3432
|
+
case "monthly":
|
|
3433
|
+
default:
|
|
3434
|
+
return "monthly";
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
function finiteNumber(value) {
|
|
3438
|
+
const n = Number(value);
|
|
3439
|
+
return Number.isFinite(n) ? n : null;
|
|
3440
|
+
}
|
|
3441
|
+
async function jsonBody(req) {
|
|
3442
|
+
const body = await req.json().catch(() => null);
|
|
3443
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body : null;
|
|
3444
|
+
}
|
|
3445
|
+
function optionalString(value) {
|
|
3446
|
+
return typeof value === "string" ? value : null;
|
|
3447
|
+
}
|
|
3448
|
+
function optionalAgent(value) {
|
|
3449
|
+
if (value == null || value === "")
|
|
3450
|
+
return null;
|
|
3451
|
+
return typeof value === "string" && AGENTS.includes(value) ? value : undefined;
|
|
3452
|
+
}
|
|
3453
|
+
function stringArray(value) {
|
|
3454
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
3455
|
+
}
|
|
3456
|
+
function dashboardPath(root, pathname) {
|
|
3457
|
+
let decoded;
|
|
3458
|
+
try {
|
|
3459
|
+
decoded = decodeURIComponent(pathname);
|
|
3460
|
+
} catch {
|
|
3461
|
+
return null;
|
|
3462
|
+
}
|
|
3463
|
+
const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
3464
|
+
const rootPath = resolve(root);
|
|
3465
|
+
const filePath = resolve(rootPath, relativePath);
|
|
3466
|
+
return filePath === rootPath || filePath.startsWith(rootPath + sep) ? filePath : null;
|
|
3467
|
+
}
|
|
3468
|
+
function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
|
|
3469
|
+
return async function fetch2(req) {
|
|
3470
|
+
const url = new URL(req.url);
|
|
3471
|
+
if (url.pathname.startsWith("/api") || url.pathname === "/health") {
|
|
3472
|
+
return apiHandler(req);
|
|
3473
|
+
}
|
|
3474
|
+
if (existsSync11(dashboardDir)) {
|
|
3475
|
+
const filePath = dashboardPath(dashboardDir, url.pathname);
|
|
3476
|
+
if (filePath && existsSync11(filePath)) {
|
|
3477
|
+
return new Response(Bun.file(filePath));
|
|
3478
|
+
}
|
|
3479
|
+
const indexPath = dashboardPath(dashboardDir, "/");
|
|
3480
|
+
if (indexPath && existsSync11(indexPath)) {
|
|
3481
|
+
return new Response(Bun.file(indexPath));
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
return apiHandler(req);
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
749
3487
|
function applyFields(obj, fields) {
|
|
750
3488
|
if (!fields || fields.length === 0)
|
|
751
3489
|
return obj;
|
|
@@ -758,11 +3496,26 @@ function createHandler(db) {
|
|
|
758
3496
|
const method = req.method;
|
|
759
3497
|
if (method === "OPTIONS")
|
|
760
3498
|
return new Response(null, { status: 204, headers: CORS });
|
|
3499
|
+
if (!isAuthorizedRequest(req, path))
|
|
3500
|
+
return err("Unauthorized", 401);
|
|
761
3501
|
if (path === "/health")
|
|
762
3502
|
return ok({ status: "ok", ts: new Date().toISOString() });
|
|
763
3503
|
if (path === "/api/summary" && method === "GET") {
|
|
764
3504
|
const period = url.searchParams.get("period") ?? "today";
|
|
765
|
-
|
|
3505
|
+
const machine = url.searchParams.get("machine") ?? undefined;
|
|
3506
|
+
return ok(querySummary(db, period, machine));
|
|
3507
|
+
}
|
|
3508
|
+
if (path === "/api/machines" && method === "GET") {
|
|
3509
|
+
return ok(listMachines(db), { current_machine: getMachineId() });
|
|
3510
|
+
}
|
|
3511
|
+
if (path === "/api/fleet" && method === "GET") {
|
|
3512
|
+
const period = url.searchParams.get("period") ?? "month";
|
|
3513
|
+
return ok({
|
|
3514
|
+
summary: querySummary(db, period, undefined, true),
|
|
3515
|
+
machines: listMachines(db),
|
|
3516
|
+
registry: listMachineRegistry(db),
|
|
3517
|
+
current_machine: getMachineId()
|
|
3518
|
+
});
|
|
766
3519
|
}
|
|
767
3520
|
if (path === "/api/daily" && method === "GET") {
|
|
768
3521
|
const days = Number(url.searchParams.get("days") ?? 30);
|
|
@@ -771,12 +3524,22 @@ function createHandler(db) {
|
|
|
771
3524
|
if (path === "/api/sessions" && method === "GET") {
|
|
772
3525
|
const agent = url.searchParams.get("agent");
|
|
773
3526
|
const project = url.searchParams.get("project") ?? undefined;
|
|
3527
|
+
const search = url.searchParams.get("search") ?? undefined;
|
|
3528
|
+
const machine = url.searchParams.get("machine") ?? undefined;
|
|
774
3529
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
775
3530
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
776
3531
|
const since = url.searchParams.get("since") ?? undefined;
|
|
777
3532
|
const fieldsParam = url.searchParams.get("fields");
|
|
778
3533
|
const fields = fieldsParam ? fieldsParam.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
|
|
779
|
-
const sessions = querySessions(db, {
|
|
3534
|
+
const sessions = querySessions(db, {
|
|
3535
|
+
agent: agent ?? undefined,
|
|
3536
|
+
project,
|
|
3537
|
+
search,
|
|
3538
|
+
machine,
|
|
3539
|
+
limit,
|
|
3540
|
+
offset,
|
|
3541
|
+
since
|
|
3542
|
+
});
|
|
780
3543
|
return ok(fields ? sessions.map((s) => applyFields(s, fields)) : sessions, { limit, offset });
|
|
781
3544
|
}
|
|
782
3545
|
if (path === "/api/top" && method === "GET") {
|
|
@@ -787,6 +3550,41 @@ function createHandler(db) {
|
|
|
787
3550
|
if (path === "/api/models" && method === "GET") {
|
|
788
3551
|
return ok(queryModelBreakdown(db));
|
|
789
3552
|
}
|
|
3553
|
+
if (path === "/api/billing" && method === "GET") {
|
|
3554
|
+
const period = url.searchParams.get("period") ?? "month";
|
|
3555
|
+
return ok(queryBillingSummary(db, period));
|
|
3556
|
+
}
|
|
3557
|
+
if (path === "/api/billing/diff" && method === "GET") {
|
|
3558
|
+
const period = url.searchParams.get("period") ?? "month";
|
|
3559
|
+
const threshold = Number(url.searchParams.get("threshold") ?? 15);
|
|
3560
|
+
return ok(queryBillingDiff(db, period, Number.isFinite(threshold) ? threshold : 15));
|
|
3561
|
+
}
|
|
3562
|
+
if (path === "/api/billing/sync" && method === "POST") {
|
|
3563
|
+
const body = await jsonBody(req) ?? {};
|
|
3564
|
+
const days = Number(body["days"] ?? 31);
|
|
3565
|
+
if (!Number.isFinite(days) || days <= 0 || days > 366)
|
|
3566
|
+
return err("days must be between 1 and 366");
|
|
3567
|
+
const providers = Array.isArray(body["providers"]) ? body["providers"] : ["anthropic", "openai", "gemini"];
|
|
3568
|
+
const allowedProviders = new Set(["anthropic", "openai", "gemini"]);
|
|
3569
|
+
if (providers.some((provider) => !allowedProviders.has(provider)))
|
|
3570
|
+
return err("invalid billing provider");
|
|
3571
|
+
const results = {};
|
|
3572
|
+
const { syncAnthropicBilling: syncAnthropicBilling2, syncOpenAIBilling: syncOpenAIBilling2, syncGeminiBilling: syncGeminiBilling2 } = await Promise.resolve().then(() => (init_billing(), exports_billing));
|
|
3573
|
+
async function capture(provider, fn) {
|
|
3574
|
+
try {
|
|
3575
|
+
results[provider] = await fn();
|
|
3576
|
+
} catch (e) {
|
|
3577
|
+
results[provider] = { error: e instanceof Error ? e.message : String(e) };
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
if (providers.includes("anthropic"))
|
|
3581
|
+
await capture("anthropic", () => syncAnthropicBilling2(db, { days }));
|
|
3582
|
+
if (providers.includes("openai"))
|
|
3583
|
+
await capture("openai", () => syncOpenAIBilling2(db, { days }));
|
|
3584
|
+
if (providers.includes("gemini"))
|
|
3585
|
+
await capture("gemini", () => syncGeminiBilling2(db, { days }));
|
|
3586
|
+
return ok(results);
|
|
3587
|
+
}
|
|
790
3588
|
if (path === "/api/projects" && method === "GET") {
|
|
791
3589
|
return ok(queryProjectBreakdown(db));
|
|
792
3590
|
}
|
|
@@ -798,38 +3596,54 @@ function createHandler(db) {
|
|
|
798
3596
|
return ok(getBudgetStatuses(db));
|
|
799
3597
|
}
|
|
800
3598
|
if (path === "/api/budgets" && method === "POST") {
|
|
801
|
-
const body = await req
|
|
3599
|
+
const body = await jsonBody(req);
|
|
3600
|
+
if (!body)
|
|
3601
|
+
return err("invalid JSON body");
|
|
3602
|
+
const limitUsd = finiteNumber(body["limit_usd"]);
|
|
3603
|
+
const alertAtPercent = finiteNumber(body["alert_at_percent"] ?? 80);
|
|
3604
|
+
if (limitUsd == null || limitUsd <= 0)
|
|
3605
|
+
return err("limit_usd must be a positive number");
|
|
3606
|
+
if (alertAtPercent == null || alertAtPercent <= 0 || alertAtPercent > 100)
|
|
3607
|
+
return err("alert_at_percent must be between 1 and 100");
|
|
3608
|
+
const agent = optionalAgent(body["agent"]);
|
|
3609
|
+
if (agent === undefined)
|
|
3610
|
+
return err(AGENT_ERROR);
|
|
802
3611
|
const now = new Date().toISOString();
|
|
803
|
-
|
|
3612
|
+
const budget = {
|
|
804
3613
|
id: randomUUID(),
|
|
805
3614
|
project_path: body["project_path"] ?? null,
|
|
806
|
-
agent
|
|
807
|
-
period: body["period"]
|
|
808
|
-
limit_usd:
|
|
809
|
-
alert_at_percent:
|
|
3615
|
+
agent,
|
|
3616
|
+
period: normalizeBudgetPeriod(body["period"]),
|
|
3617
|
+
limit_usd: limitUsd,
|
|
3618
|
+
alert_at_percent: alertAtPercent,
|
|
810
3619
|
created_at: now,
|
|
811
3620
|
updated_at: now
|
|
812
|
-
}
|
|
813
|
-
|
|
3621
|
+
};
|
|
3622
|
+
upsertBudget(db, budget);
|
|
3623
|
+
return ok(getBudgetStatuses(db).find((b) => b.id === budget.id) ?? budget);
|
|
814
3624
|
}
|
|
815
3625
|
const budgetMatch = path.match(/^\/api\/budgets\/(.+)$/);
|
|
816
3626
|
if (budgetMatch && method === "DELETE") {
|
|
817
|
-
deleteBudget(db, budgetMatch[1]);
|
|
3627
|
+
deleteBudget(db, decodeURIComponent(budgetMatch[1]));
|
|
818
3628
|
return ok({ ok: true });
|
|
819
3629
|
}
|
|
820
3630
|
if (path === "/api/project-registry" && method === "GET") {
|
|
821
3631
|
return ok(listProjects(db));
|
|
822
3632
|
}
|
|
823
3633
|
if (path === "/api/project-registry" && method === "POST") {
|
|
824
|
-
const body = await req
|
|
825
|
-
|
|
826
|
-
|
|
3634
|
+
const body = await jsonBody(req);
|
|
3635
|
+
if (!body)
|
|
3636
|
+
return err("invalid JSON body");
|
|
3637
|
+
const { basename: basename4 } = await import("path");
|
|
3638
|
+
const projPath = optionalString(body["path"])?.trim();
|
|
3639
|
+
if (!projPath)
|
|
3640
|
+
return err("path is required");
|
|
827
3641
|
upsertProject(db, {
|
|
828
3642
|
id: randomUUID(),
|
|
829
3643
|
path: projPath,
|
|
830
|
-
name: body["name"] ??
|
|
831
|
-
description: body["description"]
|
|
832
|
-
tags: body["tags"]
|
|
3644
|
+
name: optionalString(body["name"]) ?? basename4(projPath),
|
|
3645
|
+
description: optionalString(body["description"]),
|
|
3646
|
+
tags: stringArray(body["tags"]),
|
|
833
3647
|
created_at: new Date().toISOString()
|
|
834
3648
|
});
|
|
835
3649
|
return ok({ ok: true });
|
|
@@ -843,16 +3657,33 @@ function createHandler(db) {
|
|
|
843
3657
|
return ok(listModelPricing(db));
|
|
844
3658
|
}
|
|
845
3659
|
if (path === "/api/pricing" && method === "POST") {
|
|
846
|
-
const body = await req
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
3660
|
+
const body = await jsonBody(req);
|
|
3661
|
+
if (!body)
|
|
3662
|
+
return err("invalid JSON body");
|
|
3663
|
+
const model = String(body["model"] ?? "").trim();
|
|
3664
|
+
if (!model)
|
|
3665
|
+
return err("model is required");
|
|
3666
|
+
const input = finiteNumber(body["input_per_1m"]);
|
|
3667
|
+
const output = finiteNumber(body["output_per_1m"]);
|
|
3668
|
+
const cacheRead = finiteNumber(body["cache_read_per_1m"] ?? 0);
|
|
3669
|
+
const cacheWrite = finiteNumber(body["cache_write_per_1m"] ?? 0);
|
|
3670
|
+
const cacheWrite1h = finiteNumber(body["cache_write_1h_per_1m"] ?? 0);
|
|
3671
|
+
const cacheStorage = finiteNumber(body["cache_storage_per_1m_hour"] ?? 0);
|
|
3672
|
+
if ([input, output, cacheRead, cacheWrite, cacheWrite1h, cacheStorage].some((v) => v == null || v < 0)) {
|
|
3673
|
+
return err("pricing values must be non-negative numbers");
|
|
3674
|
+
}
|
|
3675
|
+
const pricing = {
|
|
3676
|
+
model,
|
|
3677
|
+
input_per_1m: input,
|
|
3678
|
+
output_per_1m: output,
|
|
3679
|
+
cache_read_per_1m: cacheRead,
|
|
3680
|
+
cache_write_per_1m: cacheWrite,
|
|
3681
|
+
cache_write_1h_per_1m: cacheWrite1h,
|
|
3682
|
+
cache_storage_per_1m_hour: cacheStorage,
|
|
853
3683
|
updated_at: new Date().toISOString()
|
|
854
|
-
}
|
|
855
|
-
|
|
3684
|
+
};
|
|
3685
|
+
upsertModelPricing(db, pricing);
|
|
3686
|
+
return ok(pricing);
|
|
856
3687
|
}
|
|
857
3688
|
const pricingMatch = path.match(/^\/api\/pricing\/(.+)$/);
|
|
858
3689
|
if (pricingMatch && method === "DELETE") {
|
|
@@ -860,18 +3691,43 @@ function createHandler(db) {
|
|
|
860
3691
|
return ok({ ok: true });
|
|
861
3692
|
}
|
|
862
3693
|
if (path === "/api/sync" && method === "POST") {
|
|
863
|
-
const body = await req
|
|
3694
|
+
const body = await jsonBody(req) ?? {};
|
|
864
3695
|
const sources = body["sources"] ?? "all";
|
|
3696
|
+
if (!SYNC_SOURCES.includes(sources))
|
|
3697
|
+
return err("invalid sync source");
|
|
865
3698
|
const results = {};
|
|
866
|
-
if (sources === "all"
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
3699
|
+
if (sources === "all") {
|
|
3700
|
+
try {
|
|
3701
|
+
const { syncOpenProjectsRegistry: syncOpenProjectsRegistry2 } = await Promise.resolve().then(() => (init_open_projects(), exports_open_projects));
|
|
3702
|
+
results["projects"] = await syncOpenProjectsRegistry2(db);
|
|
3703
|
+
} catch {}
|
|
3704
|
+
}
|
|
3705
|
+
const selected = sources === "all" ? {} : { [sources]: true };
|
|
3706
|
+
const syncResult = await syncAll(db, selected);
|
|
3707
|
+
Object.assign(results, syncResult);
|
|
3708
|
+
try {
|
|
3709
|
+
const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
|
|
3710
|
+
await checkAndFireWebhooks2(db);
|
|
3711
|
+
} catch {}
|
|
870
3712
|
return ok(results);
|
|
871
3713
|
}
|
|
3714
|
+
if (path === "/api/usage" && method === "GET") {
|
|
3715
|
+
const period = url.searchParams.get("period") ?? "month";
|
|
3716
|
+
const agent = url.searchParams.get("agent") ?? undefined;
|
|
3717
|
+
const since = period === "month" ? new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().substring(0, 10) : undefined;
|
|
3718
|
+
return ok({
|
|
3719
|
+
snapshots: queryUsageSnapshots(db, { agent: agent && isAgent(agent) ? agent : undefined, since }),
|
|
3720
|
+
summary: querySummary(db, period, undefined, true)
|
|
3721
|
+
});
|
|
3722
|
+
}
|
|
3723
|
+
if (path === "/api/savings" && method === "GET") {
|
|
3724
|
+
const period = url.searchParams.get("period") ?? "month";
|
|
3725
|
+
const agent = url.searchParams.get("agent") ?? undefined;
|
|
3726
|
+
return ok(querySavingsSummary(db, period, agent && isAgent(agent) ? agent : undefined));
|
|
3727
|
+
}
|
|
872
3728
|
const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
|
|
873
3729
|
if (sessionRequestsMatch && method === "GET") {
|
|
874
|
-
const sessionId = sessionRequestsMatch[1];
|
|
3730
|
+
const sessionId = decodeURIComponent(sessionRequestsMatch[1]);
|
|
875
3731
|
const session = db.prepare(`SELECT * FROM sessions WHERE id = ? OR id LIKE ?`).get(sessionId, `${sessionId}%`);
|
|
876
3732
|
if (!session)
|
|
877
3733
|
return err("Session not found", 404);
|
|
@@ -882,59 +3738,97 @@ function createHandler(db) {
|
|
|
882
3738
|
return ok(getGoalStatuses(db));
|
|
883
3739
|
}
|
|
884
3740
|
if (path === "/api/goals" && method === "POST") {
|
|
885
|
-
const body = await req
|
|
3741
|
+
const body = await jsonBody(req);
|
|
3742
|
+
if (!body)
|
|
3743
|
+
return err("invalid JSON body");
|
|
3744
|
+
const period = body["period"] ?? "month";
|
|
3745
|
+
if (!["day", "week", "month", "year"].includes(String(period)))
|
|
3746
|
+
return err("period must be day, week, month, or year");
|
|
3747
|
+
const limitUsd = finiteNumber(body["limit_usd"]);
|
|
3748
|
+
if (limitUsd == null || limitUsd <= 0)
|
|
3749
|
+
return err("limit_usd must be a positive number");
|
|
3750
|
+
const agent = optionalAgent(body["agent"]);
|
|
3751
|
+
if (agent === undefined)
|
|
3752
|
+
return err(AGENT_ERROR);
|
|
886
3753
|
const now = new Date().toISOString();
|
|
887
|
-
|
|
3754
|
+
const goal = {
|
|
888
3755
|
id: randomUUID(),
|
|
889
|
-
period
|
|
890
|
-
project_path: body["project_path"]
|
|
891
|
-
agent
|
|
892
|
-
limit_usd:
|
|
3756
|
+
period,
|
|
3757
|
+
project_path: optionalString(body["project_path"]),
|
|
3758
|
+
agent,
|
|
3759
|
+
limit_usd: limitUsd,
|
|
893
3760
|
created_at: now,
|
|
894
3761
|
updated_at: now
|
|
895
|
-
}
|
|
896
|
-
|
|
3762
|
+
};
|
|
3763
|
+
upsertGoal(db, goal);
|
|
3764
|
+
return ok(getGoalStatuses(db).find((g) => g.id === goal.id) ?? goal);
|
|
897
3765
|
}
|
|
898
3766
|
const goalMatch = path.match(/^\/api\/goals\/(.+)$/);
|
|
899
3767
|
if (goalMatch && method === "DELETE") {
|
|
900
|
-
deleteGoal(db, goalMatch[1]);
|
|
3768
|
+
deleteGoal(db, decodeURIComponent(goalMatch[1]));
|
|
901
3769
|
return ok({ ok: true });
|
|
902
3770
|
}
|
|
903
3771
|
return err("Not found", 404);
|
|
904
3772
|
};
|
|
905
3773
|
}
|
|
906
|
-
function startServer(port = 3456) {
|
|
907
|
-
const db = openDatabase();
|
|
3774
|
+
function startServer(port = 3456, options = {}) {
|
|
3775
|
+
const db = options.db ?? openDatabase();
|
|
908
3776
|
ensurePricingSeeded(db);
|
|
909
3777
|
const apiHandler = createHandler(db);
|
|
910
|
-
const
|
|
911
|
-
Bun.serve({
|
|
3778
|
+
const hostname2 = options.hostname ?? getServeBindHost();
|
|
3779
|
+
const server = Bun.serve({
|
|
912
3780
|
port,
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
if (url.pathname.startsWith("/api") || url.pathname === "/health") {
|
|
916
|
-
return apiHandler(req);
|
|
917
|
-
}
|
|
918
|
-
try {
|
|
919
|
-
const { existsSync: existsSync4 } = await import("fs");
|
|
920
|
-
if (existsSync4(dashboardDir)) {
|
|
921
|
-
let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
922
|
-
const fullPath = dashboardDir + filePath;
|
|
923
|
-
if (existsSync4(fullPath)) {
|
|
924
|
-
return new Response(Bun.file(fullPath));
|
|
925
|
-
}
|
|
926
|
-
const indexPath = dashboardDir + "/index.html";
|
|
927
|
-
if (existsSync4(indexPath)) {
|
|
928
|
-
return new Response(Bun.file(indexPath));
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
} catch {}
|
|
932
|
-
return apiHandler(req);
|
|
933
|
-
}
|
|
3781
|
+
hostname: hostname2,
|
|
3782
|
+
fetch: createServerFetch(apiHandler, options.dashboardDir)
|
|
934
3783
|
});
|
|
935
|
-
|
|
3784
|
+
const address = `http://${hostname2 === "0.0.0.0" ? "localhost" : hostname2}:${server.port}`;
|
|
3785
|
+
const log = options.log ?? console.log;
|
|
3786
|
+
log(`economy-serve listening on ${address}`);
|
|
3787
|
+
return server;
|
|
936
3788
|
}
|
|
937
3789
|
|
|
938
3790
|
// src/server/index.ts
|
|
939
|
-
|
|
940
|
-
|
|
3791
|
+
function printHelp() {
|
|
3792
|
+
console.log(`Usage: economy-serve [options]
|
|
3793
|
+
|
|
3794
|
+
REST API server for ${packageMetadata.name}
|
|
3795
|
+
|
|
3796
|
+
Options:
|
|
3797
|
+
-p, --port <port> Port to bind (default: ECONOMY_PORT or 3456)
|
|
3798
|
+
-V, --version output the version number
|
|
3799
|
+
-h, --help display help for command`);
|
|
3800
|
+
}
|
|
3801
|
+
function resolvePort(argv) {
|
|
3802
|
+
for (let i = 0;i < argv.length; i++) {
|
|
3803
|
+
const arg = argv[i];
|
|
3804
|
+
if (arg === "--port" || arg === "-p") {
|
|
3805
|
+
const raw = argv[i + 1];
|
|
3806
|
+
if (!raw)
|
|
3807
|
+
throw new Error(`Invalid port: ${raw ?? ""}`);
|
|
3808
|
+
return parsePort(raw, "port");
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
return parsePort(process.env["ECONOMY_PORT"] ?? "3456", "ECONOMY_PORT");
|
|
3812
|
+
}
|
|
3813
|
+
function parsePort(raw, label) {
|
|
3814
|
+
const value = Number(raw);
|
|
3815
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
3816
|
+
throw new Error(`Invalid ${label}: ${raw}`);
|
|
3817
|
+
}
|
|
3818
|
+
return value;
|
|
3819
|
+
}
|
|
3820
|
+
var args = process.argv.slice(2);
|
|
3821
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
3822
|
+
printHelp();
|
|
3823
|
+
process.exit(0);
|
|
3824
|
+
}
|
|
3825
|
+
if (args.includes("--version") || args.includes("-V")) {
|
|
3826
|
+
console.log(packageMetadata.version);
|
|
3827
|
+
process.exit(0);
|
|
3828
|
+
}
|
|
3829
|
+
try {
|
|
3830
|
+
startServer(resolvePort(args));
|
|
3831
|
+
} catch (error) {
|
|
3832
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
3833
|
+
process.exit(1);
|
|
3834
|
+
}
|