@hasna/economy 0.2.20 → 0.2.22

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.
Files changed (88) hide show
  1. package/LICENSE +1 -2
  2. package/README.md +5 -13
  3. package/dist/cli/commands/completion.d.ts +2 -0
  4. package/dist/cli/commands/completion.d.ts.map +1 -0
  5. package/dist/cli/commands/extras.d.ts +4 -0
  6. package/dist/cli/commands/extras.d.ts.map +1 -0
  7. package/dist/cli/commands/menubar.d.ts.map +1 -1
  8. package/dist/cli/commands/notification.d.ts +8 -0
  9. package/dist/cli/commands/notification.d.ts.map +1 -0
  10. package/dist/cli/commands/todos.d.ts +26 -0
  11. package/dist/cli/commands/todos.d.ts.map +1 -0
  12. package/dist/cli/commands/tui.d.ts +10 -0
  13. package/dist/cli/commands/tui.d.ts.map +1 -0
  14. package/dist/cli/commands/watch.d.ts +1 -0
  15. package/dist/cli/commands/watch.d.ts.map +1 -1
  16. package/dist/cli/index.js +5649 -708
  17. package/dist/db/database.d.ts +45 -3
  18. package/dist/db/database.d.ts.map +1 -1
  19. package/dist/db/pg-migrations.d.ts.map +1 -1
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1576 -142
  23. package/dist/ingest/billing.d.ts +27 -0
  24. package/dist/ingest/billing.d.ts.map +1 -0
  25. package/dist/ingest/claude-quota.d.ts +5 -0
  26. package/dist/ingest/claude-quota.d.ts.map +1 -0
  27. package/dist/ingest/claude.d.ts +13 -2
  28. package/dist/ingest/claude.d.ts.map +1 -1
  29. package/dist/ingest/codex-quota.d.ts +5 -0
  30. package/dist/ingest/codex-quota.d.ts.map +1 -0
  31. package/dist/ingest/codex.d.ts +2 -1
  32. package/dist/ingest/codex.d.ts.map +1 -1
  33. package/dist/ingest/cursor.d.ts +6 -0
  34. package/dist/ingest/cursor.d.ts.map +1 -0
  35. package/dist/ingest/gemini.d.ts +2 -1
  36. package/dist/ingest/gemini.d.ts.map +1 -1
  37. package/dist/ingest/hermes.d.ts +6 -0
  38. package/dist/ingest/hermes.d.ts.map +1 -0
  39. package/dist/ingest/opencode.d.ts +7 -0
  40. package/dist/ingest/opencode.d.ts.map +1 -0
  41. package/dist/ingest/otel.d.ts +20 -0
  42. package/dist/ingest/otel.d.ts.map +1 -0
  43. package/dist/ingest/pi.d.ts +7 -0
  44. package/dist/ingest/pi.d.ts.map +1 -0
  45. package/dist/ingest/plugin.d.ts +17 -0
  46. package/dist/ingest/plugin.d.ts.map +1 -0
  47. package/dist/lib/accounts.d.ts +11 -0
  48. package/dist/lib/accounts.d.ts.map +1 -0
  49. package/dist/lib/agents.d.ts +11 -0
  50. package/dist/lib/agents.d.ts.map +1 -0
  51. package/dist/lib/billing-diff.d.ts +22 -0
  52. package/dist/lib/billing-diff.d.ts.map +1 -0
  53. package/dist/lib/cloud-sync.d.ts +35 -0
  54. package/dist/lib/cloud-sync.d.ts.map +1 -0
  55. package/dist/lib/config.d.ts.map +1 -1
  56. package/dist/lib/gatherer.d.ts.map +1 -1
  57. package/dist/lib/model-config.d.ts.map +1 -1
  58. package/dist/lib/open-projects.d.ts +19 -0
  59. package/dist/lib/open-projects.d.ts.map +1 -0
  60. package/dist/lib/package-metadata.d.ts +8 -0
  61. package/dist/lib/package-metadata.d.ts.map +1 -0
  62. package/dist/lib/paths.d.ts +20 -0
  63. package/dist/lib/paths.d.ts.map +1 -0
  64. package/dist/lib/pricing.d.ts +3 -3
  65. package/dist/lib/pricing.d.ts.map +1 -1
  66. package/dist/lib/savings.d.ts +17 -0
  67. package/dist/lib/savings.d.ts.map +1 -0
  68. package/dist/lib/serve-auth.d.ts +4 -0
  69. package/dist/lib/serve-auth.d.ts.map +1 -0
  70. package/dist/lib/spikes.d.ts +18 -0
  71. package/dist/lib/spikes.d.ts.map +1 -0
  72. package/dist/lib/sync-all.d.ts +28 -0
  73. package/dist/lib/sync-all.d.ts.map +1 -0
  74. package/dist/lib/watch-paths.d.ts +3 -0
  75. package/dist/lib/watch-paths.d.ts.map +1 -0
  76. package/dist/lib/webhooks.d.ts +1 -1
  77. package/dist/lib/webhooks.d.ts.map +1 -1
  78. package/dist/mcp/index.js +3063 -482
  79. package/dist/otel/index.d.ts +3 -0
  80. package/dist/otel/index.d.ts.map +1 -0
  81. package/dist/otel/index.js +1423 -0
  82. package/dist/server/index.d.ts +1 -0
  83. package/dist/server/index.js +3550 -269
  84. package/dist/server/serve.d.ts +10 -2
  85. package/dist/server/serve.d.ts.map +1 -1
  86. package/dist/types/index.d.ts +102 -6
  87. package/dist/types/index.d.ts.map +1 -1
  88. package/package.json +9 -4
@@ -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}$/, "").toLowerCase();
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
- const normalized = normalizeModelName(model);
38
- const row = getModelPricing(db, normalized);
39
- if (row) {
40
- return {
41
- inputPer1M: row.input_per_1m,
42
- outputPer1M: row.output_per_1m,
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
- for (const r of allRows) {
49
- if (normalized.startsWith(r.model)) {
50
- return { inputPer1M: r.input_per_1m, outputPer1M: r.output_per_1m, cacheReadPer1M: r.cache_read_per_1m, cacheWritePer1M: r.cache_write_per_1m };
51
- }
52
- }
53
- return null;
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
- const normalized = normalizeModelName(model);
57
- if (DEFAULT_PRICING[normalized])
58
- return DEFAULT_PRICING[normalized] ?? null;
59
- for (const key of Object.keys(DEFAULT_PRICING)) {
60
- if (normalized.startsWith(key))
61
- return DEFAULT_PRICING[key] ?? null;
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 (inputTokens * pricing.inputPer1M + outputTokens * pricing.outputPer1M + cacheReadTokens * pricing.cacheReadPer1M + cacheWriteTokens * pricing.cacheWritePer1M) / 1e6;
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 (inputTokens * pricing.inputPer1M + outputTokens * pricing.outputPer1M + cacheReadTokens * pricing.cacheReadPer1M + cacheWriteTokens * pricing.cacheWritePer1M) / 1e6;
214
+ return computeCostWithPricing(model, pricing, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens, cacheStorageTokenHours);
76
215
  }
77
- var DEFAULT_PRICING;
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-6": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
82
- "claude-opus-4-5": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
83
- "claude-sonnet-4-6": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
84
- "claude-sonnet-4-5": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
85
- "claude-haiku-4-5": { inputPer1M: 1, outputPer1M: 5, cacheReadPer1M: 0.1, cacheWritePer1M: 1.25 },
86
- "claude-3-5-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
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-3-opus": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
89
- "claude-3-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
90
- "claude-3-haiku": { inputPer1M: 0.25, outputPer1M: 1.25, cacheReadPer1M: 0.03, cacheWritePer1M: 0.3 },
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-4o": { inputPer1M: 2.5, outputPer1M: 10, cacheReadPer1M: 1.25, cacheWritePer1M: 0 },
99
- "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
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
- o3: { inputPer1M: 10, outputPer1M: 40, cacheReadPer1M: 2.5, cacheWritePer1M: 0 },
103
- "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.55, cacheWritePer1M: 0 },
104
- "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.275, cacheWritePer1M: 0 }
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,18 @@ 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 '',
587
+ account_key TEXT DEFAULT '',
588
+ account_tool TEXT DEFAULT '',
589
+ account_name TEXT DEFAULT '',
590
+ account_email TEXT DEFAULT '',
591
+ account_source TEXT DEFAULT ''
167
592
  );
168
593
 
169
594
  CREATE TABLE IF NOT EXISTS sessions (
@@ -175,7 +600,13 @@ function initSchema(db) {
175
600
  ended_at TEXT,
176
601
  total_cost_usd REAL DEFAULT 0,
177
602
  total_tokens INTEGER DEFAULT 0,
178
- request_count INTEGER DEFAULT 0
603
+ request_count INTEGER DEFAULT 0,
604
+ machine_id TEXT DEFAULT '',
605
+ account_key TEXT DEFAULT '',
606
+ account_tool TEXT DEFAULT '',
607
+ account_name TEXT DEFAULT '',
608
+ account_email TEXT DEFAULT '',
609
+ account_source TEXT DEFAULT ''
179
610
  );
180
611
 
181
612
  CREATE TABLE IF NOT EXISTS projects (
@@ -228,6 +659,8 @@ function initSchema(db) {
228
659
  output_per_1m REAL NOT NULL DEFAULT 0,
229
660
  cache_read_per_1m REAL NOT NULL DEFAULT 0,
230
661
  cache_write_per_1m REAL NOT NULL DEFAULT 0,
662
+ cache_write_1h_per_1m REAL NOT NULL DEFAULT 0,
663
+ cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0,
231
664
  updated_at TEXT NOT NULL
232
665
  );
233
666
 
@@ -240,6 +673,127 @@ function initSchema(db) {
240
673
  machine_id TEXT,
241
674
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
242
675
  );
676
+
677
+ CREATE TABLE IF NOT EXISTS billing_daily (
678
+ date TEXT NOT NULL,
679
+ provider TEXT NOT NULL,
680
+ description TEXT DEFAULT '',
681
+ cost_usd REAL NOT NULL DEFAULT 0,
682
+ updated_at TEXT NOT NULL,
683
+ PRIMARY KEY (date, provider, description)
684
+ );
685
+
686
+ CREATE INDEX IF NOT EXISTS idx_billing_date ON billing_daily(date);
687
+ CREATE INDEX IF NOT EXISTS idx_billing_provider ON billing_daily(provider);
688
+
689
+ CREATE TABLE IF NOT EXISTS subscriptions (
690
+ id TEXT PRIMARY KEY,
691
+ agent TEXT,
692
+ provider TEXT NOT NULL,
693
+ plan TEXT NOT NULL,
694
+ monthly_fee_usd REAL NOT NULL DEFAULT 0,
695
+ included_usage_usd REAL NOT NULL DEFAULT 0,
696
+ billing_cycle_start TEXT,
697
+ reset_policy TEXT DEFAULT 'monthly',
698
+ active INTEGER NOT NULL DEFAULT 1,
699
+ created_at TEXT NOT NULL,
700
+ updated_at TEXT NOT NULL
701
+ );
702
+
703
+ CREATE TABLE IF NOT EXISTS usage_snapshots (
704
+ id TEXT PRIMARY KEY,
705
+ agent TEXT NOT NULL,
706
+ date TEXT NOT NULL,
707
+ metric TEXT NOT NULL,
708
+ value REAL NOT NULL DEFAULT 0,
709
+ unit TEXT DEFAULT '',
710
+ machine_id TEXT DEFAULT '',
711
+ updated_at TEXT NOT NULL
712
+ );
713
+
714
+ CREATE TABLE IF NOT EXISTS savings_daily (
715
+ date TEXT NOT NULL,
716
+ agent TEXT DEFAULT '',
717
+ api_equivalent_usd REAL NOT NULL DEFAULT 0,
718
+ subscription_fee_usd REAL NOT NULL DEFAULT 0,
719
+ included_consumed_usd REAL NOT NULL DEFAULT 0,
720
+ on_demand_usd REAL NOT NULL DEFAULT 0,
721
+ saved_usd REAL NOT NULL DEFAULT 0,
722
+ updated_at TEXT NOT NULL,
723
+ PRIMARY KEY (date, agent)
724
+ );
725
+
726
+ CREATE TABLE IF NOT EXISTS machines (
727
+ machine_id TEXT PRIMARY KEY,
728
+ hostname TEXT NOT NULL,
729
+ last_seen_at TEXT,
730
+ last_push_at TEXT,
731
+ last_pull_at TEXT,
732
+ economy_version TEXT,
733
+ updated_at TEXT NOT NULL
734
+ );
735
+
736
+ CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date);
737
+ CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date);
738
+ `);
739
+ const cols = db.prepare(`PRAGMA table_info(requests)`).all();
740
+ if (!cols.some((c) => c.name === "machine_id")) {
741
+ db.exec(`ALTER TABLE requests ADD COLUMN machine_id TEXT DEFAULT ''`);
742
+ db.exec(`ALTER TABLE sessions ADD COLUMN machine_id TEXT DEFAULT ''`);
743
+ }
744
+ if (!cols.some((c) => c.name === "cache_create_5m_tokens")) {
745
+ db.exec(`ALTER TABLE requests ADD COLUMN cache_create_5m_tokens INTEGER DEFAULT 0`);
746
+ db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
747
+ }
748
+ if (!cols.some((c) => c.name === "cache_create_1h_tokens")) {
749
+ db.exec(`ALTER TABLE requests ADD COLUMN cache_create_1h_tokens INTEGER DEFAULT 0`);
750
+ }
751
+ if (!cols.some((c) => c.name === "cost_basis")) {
752
+ db.exec(`ALTER TABLE requests ADD COLUMN cost_basis TEXT DEFAULT 'estimated'`);
753
+ }
754
+ if (!cols.some((c) => c.name === "attribution_tag")) {
755
+ db.exec(`ALTER TABLE requests ADD COLUMN attribution_tag TEXT DEFAULT ''`);
756
+ }
757
+ if (!cols.some((c) => c.name === "updated_at")) {
758
+ db.exec(`ALTER TABLE requests ADD COLUMN updated_at TEXT DEFAULT ''`);
759
+ db.exec(`UPDATE requests SET updated_at = timestamp WHERE updated_at = '' OR updated_at IS NULL`);
760
+ }
761
+ if (!cols.some((c) => c.name === "synced_at")) {
762
+ db.exec(`ALTER TABLE requests ADD COLUMN synced_at TEXT DEFAULT ''`);
763
+ }
764
+ for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
765
+ if (!cols.some((c) => c.name === column)) {
766
+ db.exec(`ALTER TABLE requests ADD COLUMN ${column} TEXT DEFAULT ''`);
767
+ }
768
+ }
769
+ const sessionCols = db.prepare(`PRAGMA table_info(sessions)`).all();
770
+ if (!sessionCols.some((c) => c.name === "attribution_tag")) {
771
+ db.exec(`ALTER TABLE sessions ADD COLUMN attribution_tag TEXT DEFAULT ''`);
772
+ }
773
+ if (!sessionCols.some((c) => c.name === "updated_at")) {
774
+ db.exec(`ALTER TABLE sessions ADD COLUMN updated_at TEXT DEFAULT ''`);
775
+ db.exec(`UPDATE sessions SET updated_at = started_at WHERE updated_at = '' OR updated_at IS NULL`);
776
+ }
777
+ if (!sessionCols.some((c) => c.name === "synced_at")) {
778
+ db.exec(`ALTER TABLE sessions ADD COLUMN synced_at TEXT DEFAULT ''`);
779
+ }
780
+ for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
781
+ if (!sessionCols.some((c) => c.name === column)) {
782
+ db.exec(`ALTER TABLE sessions ADD COLUMN ${column} TEXT DEFAULT ''`);
783
+ }
784
+ }
785
+ const pricingCols = db.prepare(`PRAGMA table_info(model_pricing)`).all();
786
+ if (!pricingCols.some((c) => c.name === "cache_write_1h_per_1m")) {
787
+ db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`);
788
+ }
789
+ if (!pricingCols.some((c) => c.name === "cache_storage_per_1m_hour")) {
790
+ db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`);
791
+ }
792
+ db.exec(`
793
+ CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
794
+ CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
795
+ CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key);
796
+ CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key);
243
797
  `);
244
798
  }
245
799
  function periodWhere(period) {
@@ -249,11 +803,11 @@ function periodWhere(period) {
249
803
  case "yesterday":
250
804
  return `DATE(timestamp) = DATE('now', '-1 day')`;
251
805
  case "week":
252
- return `timestamp >= DATE('now', '-7 days')`;
806
+ return `timestamp >= DATE('now', 'weekday 0', '-7 days')`;
253
807
  case "month":
254
- return `timestamp >= DATE('now', '-30 days')`;
808
+ return `timestamp >= DATE('now', 'start of month')`;
255
809
  case "year":
256
- return `timestamp >= DATE('now', '-365 days')`;
810
+ return `timestamp >= DATE('now', 'start of year')`;
257
811
  case "all":
258
812
  return "1=1";
259
813
  }
@@ -265,31 +819,52 @@ function sessionPeriodWhere(period) {
265
819
  case "yesterday":
266
820
  return `DATE(started_at) = DATE('now', '-1 day')`;
267
821
  case "week":
268
- return `started_at >= DATE('now', '-7 days')`;
822
+ return `started_at >= DATE('now', 'weekday 0', '-7 days')`;
823
+ case "month":
824
+ return `started_at >= DATE('now', 'start of month')`;
825
+ case "year":
826
+ return `started_at >= DATE('now', 'start of year')`;
827
+ case "all":
828
+ return "1=1";
829
+ }
830
+ }
831
+ function requestPeriodWhere(period) {
832
+ switch (period) {
833
+ case "today":
834
+ return `DATE(timestamp) = DATE('now')`;
835
+ case "yesterday":
836
+ return `DATE(timestamp) = DATE('now', '-1 day')`;
837
+ case "week":
838
+ return `timestamp >= DATE('now', 'weekday 0', '-7 days')`;
269
839
  case "month":
270
- return `started_at >= DATE('now', '-30 days')`;
840
+ return `timestamp >= DATE('now', 'start of month')`;
271
841
  case "year":
272
- return `started_at >= DATE('now', '-365 days')`;
842
+ return `timestamp >= DATE('now', 'start of year')`;
273
843
  case "all":
274
844
  return "1=1";
275
845
  }
276
846
  }
277
847
  function upsertRequest(db, req) {
848
+ const now = req.updated_at ?? new Date().toISOString();
278
849
  db.prepare(`
279
850
  INSERT OR REPLACE INTO requests
280
851
  (id, agent, session_id, model, input_tokens, output_tokens,
281
- cache_read_tokens, cache_create_tokens, cost_usd, duration_ms,
282
- timestamp, source_request_id)
283
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
284
- `).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.cost_usd, req.duration_ms, req.timestamp, req.source_request_id);
852
+ cache_read_tokens, cache_create_tokens, cache_create_5m_tokens,
853
+ cache_create_1h_tokens, cost_usd, cost_basis, duration_ms, timestamp,
854
+ source_request_id, machine_id, attribution_tag, account_key, account_tool,
855
+ account_name, account_email, account_source, updated_at, synced_at)
856
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
857
+ `).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"] ?? "", req.account_key ?? "", req.account_tool ?? "", req.account_name ?? "", req.account_email ?? "", req.account_source ?? "", now, req.synced_at ?? "");
285
858
  }
286
859
  function upsertSession(db, session) {
860
+ const now = session.updated_at ?? new Date().toISOString();
287
861
  db.prepare(`
288
862
  INSERT OR REPLACE INTO sessions
289
863
  (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);
864
+ total_cost_usd, total_tokens, request_count, machine_id, attribution_tag,
865
+ account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
866
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
867
+ `).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"] ?? "", session.account_key ?? "", session.account_tool ?? "", session.account_name ?? "", session.account_email ?? "", session.account_source ?? "", now, session.synced_at ?? "");
293
868
  }
294
869
  function rollupSession(db, sessionId) {
295
870
  db.prepare(`
@@ -300,9 +875,24 @@ function rollupSession(db, sessionId) {
300
875
  ended_at = (SELECT MAX(timestamp) FROM requests WHERE session_id = ?),
301
876
  started_at = CASE WHEN started_at = '' OR started_at IS NULL
302
877
  THEN (SELECT MIN(timestamp) FROM requests WHERE session_id = ?)
303
- ELSE started_at END
878
+ ELSE started_at END,
879
+ account_key = CASE WHEN account_key = '' OR account_key IS NULL
880
+ THEN COALESCE((SELECT account_key FROM requests WHERE session_id = ? AND account_key != '' ORDER BY timestamp DESC LIMIT 1), '')
881
+ ELSE account_key END,
882
+ account_tool = CASE WHEN account_tool = '' OR account_tool IS NULL
883
+ THEN COALESCE((SELECT account_tool FROM requests WHERE session_id = ? AND account_tool != '' ORDER BY timestamp DESC LIMIT 1), '')
884
+ ELSE account_tool END,
885
+ account_name = CASE WHEN account_name = '' OR account_name IS NULL
886
+ THEN COALESCE((SELECT account_name FROM requests WHERE session_id = ? AND account_name != '' ORDER BY timestamp DESC LIMIT 1), '')
887
+ ELSE account_name END,
888
+ account_email = CASE WHEN account_email = '' OR account_email IS NULL
889
+ THEN COALESCE((SELECT account_email FROM requests WHERE session_id = ? AND account_email != '' ORDER BY timestamp DESC LIMIT 1), '')
890
+ ELSE account_email END,
891
+ account_source = CASE WHEN account_source = '' OR account_source IS NULL
892
+ THEN COALESCE((SELECT account_source FROM requests WHERE session_id = ? AND account_source != '' ORDER BY timestamp DESC LIMIT 1), '')
893
+ ELSE account_source END
304
894
  WHERE id = ?
305
- `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
895
+ `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
306
896
  }
307
897
  function querySessions(db, filter = {}) {
308
898
  const conditions = [];
@@ -315,10 +905,19 @@ function querySessions(db, filter = {}) {
315
905
  conditions.push("project_path LIKE ?");
316
906
  params.push(`%${filter.project}%`);
317
907
  }
908
+ if (filter.account) {
909
+ const q = `%${filter.account}%`;
910
+ conditions.push("(account_key LIKE ? OR account_name LIKE ? OR account_email LIKE ?)");
911
+ params.push(q, q, q);
912
+ }
318
913
  if (filter.since) {
319
914
  conditions.push("started_at >= ?");
320
915
  params.push(filter.since);
321
916
  }
917
+ if (filter.machine) {
918
+ conditions.push("machine_id = ?");
919
+ params.push(filter.machine);
920
+ }
322
921
  if (filter.search) {
323
922
  const q = `%${filter.search}%`;
324
923
  conditions.push("(project_name LIKE ? OR agent LIKE ? OR id LIKE ?)");
@@ -337,24 +936,25 @@ function queryTopSessions(db, n = 10, agent) {
337
936
  }
338
937
  return db.prepare(`SELECT * FROM sessions ORDER BY total_cost_usd DESC LIMIT ?`).all(n);
339
938
  }
340
- function querySummary(db, period) {
939
+ function querySummary(db, period, machine, allMachines = false) {
341
940
  const rWhere = periodWhere(period);
342
941
  const sWhere = sessionPeriodWhere(period);
942
+ const machineClause = !allMachines && machine ? ` AND machine_id = '${machine.replace(/'/g, "''")}'` : "";
343
943
  const r = db.prepare(`
344
944
  SELECT COALESCE(SUM(cost_usd), 0) as total_usd,
345
945
  COUNT(*) as requests,
346
946
  COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as tokens
347
- FROM requests WHERE ${rWhere}
947
+ FROM requests WHERE ${rWhere}${machineClause}
348
948
  `).get();
349
949
  const codexTotals = db.prepare(`
350
950
  SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
351
951
  COALESCE(SUM(total_tokens), 0) as tokens,
352
952
  COUNT(*) as sessions
353
953
  FROM sessions
354
- WHERE ${sWhere}
954
+ WHERE ${sWhere}${machineClause}
355
955
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
356
956
  `).get();
357
- const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}`).get();
957
+ const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}${machineClause}`).get();
358
958
  return {
359
959
  total_usd: r.total_usd + codexTotals.cost_usd,
360
960
  requests: r.requests,
@@ -374,23 +974,213 @@ function queryModelBreakdown(db) {
374
974
  FROM requests GROUP BY model, agent ORDER BY cost_usd DESC
375
975
  `).all();
376
976
  }
377
- function queryProjectBreakdown(db) {
378
- return db.prepare(`
379
- SELECT
380
- s.project_path,
381
- COALESCE(p.name, s.project_name) as project_name,
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
977
+ function queryAgentBreakdown(db, period = "all") {
978
+ const requestWhere = requestPeriodWhere(period);
979
+ const groups = new Map;
980
+ const requestRows = db.prepare(`
981
+ SELECT agent,
982
+ COUNT(DISTINCT session_id) as sessions,
983
+ COUNT(*) as requests,
984
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
985
+ COALESCE(SUM(cost_usd), 0) as api_equivalent_usd,
986
+ COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as metered_api_usd,
987
+ COALESCE(SUM(CASE WHEN cost_basis = 'subscription_included' THEN cost_usd ELSE 0 END), 0) as subscription_included_usd,
988
+ COALESCE(SUM(CASE WHEN COALESCE(cost_basis, 'estimated') = 'estimated' THEN cost_usd ELSE 0 END), 0) as estimated_usd,
989
+ COALESCE(SUM(CASE WHEN cost_basis = 'unknown' THEN cost_usd ELSE 0 END), 0) as unknown_usd,
990
+ COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as billable_usd,
991
+ COALESCE(SUM(cost_usd), 0) as cost_usd,
992
+ MAX(timestamp) as last_active
993
+ FROM requests
994
+ WHERE ${requestWhere}
995
+ GROUP BY agent
996
+ ORDER BY api_equivalent_usd DESC
997
+ `).all();
998
+ for (const row of requestRows) {
999
+ groups.set(row.agent, row);
1000
+ }
1001
+ const sessionWhere = sessionPeriodWhere(period);
1002
+ const sessionOnlyRows = db.prepare(`
1003
+ SELECT agent,
1004
+ COUNT(*) as sessions,
1005
+ COALESCE(SUM(request_count), 0) as requests,
1006
+ COALESCE(SUM(total_tokens), 0) as total_tokens,
1007
+ COALESCE(SUM(total_cost_usd), 0) as cost_usd,
1008
+ MAX(started_at) as last_active
1009
+ FROM sessions
1010
+ WHERE ${sessionWhere}
1011
+ AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1012
+ GROUP BY agent
1013
+ `).all();
1014
+ for (const row of sessionOnlyRows) {
1015
+ const existing = groups.get(row.agent) ?? {
1016
+ agent: row.agent,
1017
+ sessions: 0,
1018
+ requests: 0,
1019
+ total_tokens: 0,
1020
+ api_equivalent_usd: 0,
1021
+ billable_usd: 0,
1022
+ metered_api_usd: 0,
1023
+ subscription_included_usd: 0,
1024
+ estimated_usd: 0,
1025
+ unknown_usd: 0,
1026
+ cost_usd: 0,
1027
+ last_active: ""
1028
+ };
1029
+ existing.sessions += row.sessions;
1030
+ existing.requests += row.requests;
1031
+ existing.total_tokens += row.total_tokens;
1032
+ existing.api_equivalent_usd += row.cost_usd;
1033
+ existing.estimated_usd += row.cost_usd;
1034
+ existing.cost_usd += row.cost_usd;
1035
+ if (!existing.last_active || row.last_active > existing.last_active)
1036
+ existing.last_active = row.last_active;
1037
+ groups.set(row.agent, existing);
1038
+ }
1039
+ return [...groups.values()].sort((a, b) => b.api_equivalent_usd - a.api_equivalent_usd);
1040
+ }
1041
+ function labelForPath(projectPath, projectName) {
1042
+ if (projectName && projectName.trim() !== "")
1043
+ return projectName;
1044
+ if (!projectPath)
1045
+ return "";
1046
+ const segments = projectPath.split("/").filter(Boolean);
1047
+ const projectPrefix = /^(open|skill|hook|service|connect|platform|agent|tool|iapp|project|scaffold|capp)-/;
1048
+ for (const seg of segments) {
1049
+ if (projectPrefix.test(seg))
1050
+ return seg;
1051
+ }
1052
+ const generic = new Set(["web", "app", "apps", "packages", "src", "lib", "server", "client", "api", "frontend", "backend"]);
1053
+ for (let i = segments.length - 1;i >= 0; i--) {
1054
+ if (!generic.has(segments[i].toLowerCase()))
1055
+ return segments[i];
1056
+ }
1057
+ return segments[segments.length - 1] ?? projectPath;
1058
+ }
1059
+ function queryProjectBreakdown(db, period = "all") {
1060
+ const where = sessionPeriodWhere(period);
1061
+ const sessions = db.prepare(`
1062
+ SELECT id, project_path, project_name, total_cost_usd, started_at
1063
+ FROM sessions
1064
+ WHERE ${where}
1065
+ AND (project_path != '' OR project_name != '')
1066
+ `).all();
1067
+ const groups = new Map;
1068
+ for (const s of sessions) {
1069
+ const label = labelForPath(s.project_path, s.project_name);
1070
+ if (!label)
1071
+ continue;
1072
+ const g = groups.get(label) ?? { sessionIds: [], samplePath: s.project_path, totalCost: 0, lastActive: "" };
1073
+ g.sessionIds.push(s.id);
1074
+ g.totalCost += s.total_cost_usd || 0;
1075
+ if (!g.lastActive || s.started_at > g.lastActive)
1076
+ g.lastActive = s.started_at;
1077
+ if (!g.samplePath)
1078
+ g.samplePath = s.project_path;
1079
+ groups.set(label, g);
1080
+ }
1081
+ const result = [];
1082
+ for (const [label, g] of groups.entries()) {
1083
+ const placeholders = g.sessionIds.map(() => "?").join(",");
1084
+ const reqStats = placeholders.length ? db.prepare(`
1085
+ SELECT
1086
+ COUNT(*) as requests,
1087
+ COALESCE(SUM(cost_usd), 0) as cost_usd,
1088
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens
1089
+ FROM requests WHERE session_id IN (${placeholders})
1090
+ `).get(...g.sessionIds) : { requests: 0, cost_usd: 0, total_tokens: 0 };
1091
+ result.push({
1092
+ project_path: g.samplePath,
1093
+ project_name: label,
1094
+ sessions: g.sessionIds.length,
1095
+ requests: reqStats.requests,
1096
+ total_tokens: reqStats.total_tokens,
1097
+ cost_usd: reqStats.cost_usd > 0 ? reqStats.cost_usd : g.totalCost,
1098
+ last_active: g.lastActive
1099
+ });
1100
+ }
1101
+ result.sort((a, b) => b.cost_usd - a.cost_usd);
1102
+ return result;
1103
+ }
1104
+ function queryAccountBreakdown(db, period = "all") {
1105
+ const sWhere = sessionPeriodWhere(period);
1106
+ const sessions = db.prepare(`
1107
+ SELECT id, account_key, account_tool, account_name, account_email, account_source,
1108
+ total_cost_usd, total_tokens, request_count, started_at
1109
+ FROM sessions
1110
+ WHERE ${sWhere}
1111
+ AND (account_key != '' OR account_tool != '' OR account_name != '' OR account_email != '')
393
1112
  `).all();
1113
+ const groups = new Map;
1114
+ for (const session of sessions) {
1115
+ const key = session.account_key || `${session.account_tool}:${session.account_name}`;
1116
+ if (!key || key === ":")
1117
+ continue;
1118
+ const group = groups.get(key) ?? {
1119
+ sessionIds: [],
1120
+ account_tool: session.account_tool,
1121
+ account_name: session.account_name,
1122
+ account_email: session.account_email || null,
1123
+ account_source: session.account_source || "unknown",
1124
+ totalCost: 0,
1125
+ totalTokens: 0,
1126
+ requests: 0,
1127
+ lastActive: ""
1128
+ };
1129
+ group.sessionIds.push(session.id);
1130
+ group.totalCost += session.total_cost_usd || 0;
1131
+ group.totalTokens += session.total_tokens || 0;
1132
+ group.requests += session.request_count || 0;
1133
+ if (!group.lastActive || session.started_at > group.lastActive)
1134
+ group.lastActive = session.started_at;
1135
+ groups.set(key, group);
1136
+ }
1137
+ const result = [];
1138
+ for (const [key, group] of groups.entries()) {
1139
+ const placeholders = group.sessionIds.map(() => "?").join(",");
1140
+ const reqStats = placeholders ? db.prepare(`
1141
+ SELECT
1142
+ COUNT(*) as requests,
1143
+ COALESCE(SUM(cost_usd), 0) as cost_usd,
1144
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
1145
+ COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as metered_api_usd,
1146
+ COALESCE(SUM(CASE WHEN cost_basis = 'subscription_included' THEN cost_usd ELSE 0 END), 0) as subscription_included_usd,
1147
+ COALESCE(SUM(CASE WHEN COALESCE(cost_basis, 'estimated') = 'estimated' THEN cost_usd ELSE 0 END), 0) as estimated_usd,
1148
+ COALESCE(SUM(CASE WHEN cost_basis = 'unknown' THEN cost_usd ELSE 0 END), 0) as unknown_usd
1149
+ FROM requests WHERE session_id IN (${placeholders})
1150
+ `).get(...group.sessionIds) : {
1151
+ requests: 0,
1152
+ cost_usd: 0,
1153
+ total_tokens: 0,
1154
+ metered_api_usd: 0,
1155
+ subscription_included_usd: 0,
1156
+ estimated_usd: 0,
1157
+ unknown_usd: 0
1158
+ };
1159
+ const hasRequestCosts = reqStats.requests > 0;
1160
+ const apiEquivalentUsd = hasRequestCosts ? reqStats.cost_usd : group.totalCost;
1161
+ const estimatedUsd = hasRequestCosts ? reqStats.estimated_usd : group.totalCost;
1162
+ const billableUsd = reqStats.metered_api_usd;
1163
+ result.push({
1164
+ account_key: key,
1165
+ account_tool: group.account_tool,
1166
+ account_name: group.account_name,
1167
+ account_email: group.account_email,
1168
+ account_source: group.account_source,
1169
+ sessions: group.sessionIds.length,
1170
+ requests: reqStats.requests || group.requests,
1171
+ total_tokens: reqStats.total_tokens || group.totalTokens,
1172
+ api_equivalent_usd: apiEquivalentUsd,
1173
+ billable_usd: billableUsd,
1174
+ metered_api_usd: reqStats.metered_api_usd,
1175
+ subscription_included_usd: reqStats.subscription_included_usd,
1176
+ estimated_usd: estimatedUsd,
1177
+ unknown_usd: reqStats.unknown_usd,
1178
+ cost_usd: apiEquivalentUsd,
1179
+ last_active: group.lastActive
1180
+ });
1181
+ }
1182
+ result.sort((a, b) => b.cost_usd - a.cost_usd);
1183
+ return result;
394
1184
  }
395
1185
  function queryDailyBreakdown(db, days = 30) {
396
1186
  return db.prepare(`
@@ -499,12 +1289,46 @@ function getIngestState(db, source, key) {
499
1289
  function setIngestState(db, source, key, value) {
500
1290
  db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES (?, ?, ?)`).run(source, key, value);
501
1291
  }
1292
+ function upsertBillingDaily(db, row) {
1293
+ db.prepare(`
1294
+ INSERT OR REPLACE INTO billing_daily (date, provider, description, cost_usd, updated_at)
1295
+ VALUES (?, ?, ?, ?, ?)
1296
+ `).run(row.date, row.provider, row.description, row.cost_usd, row.updated_at);
1297
+ }
1298
+ function clearBillingRange(db, provider, fromDate, toDate) {
1299
+ db.prepare(`DELETE FROM billing_daily WHERE provider = ? AND date >= ? AND date <= ?`).run(provider, fromDate, toDate);
1300
+ }
1301
+ function queryBillingSummary(db, period) {
1302
+ 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";
1303
+ const rows = db.prepare(`SELECT provider, SUM(cost_usd) as cost FROM billing_daily WHERE ${where} GROUP BY provider`).all();
1304
+ const by_provider = {};
1305
+ let total = 0;
1306
+ for (const r of rows) {
1307
+ by_provider[r.provider] = r.cost;
1308
+ total += r.cost;
1309
+ }
1310
+ return { total_usd: total, by_provider };
1311
+ }
1312
+ function listMachines(db) {
1313
+ return db.prepare(`
1314
+ SELECT
1315
+ s.machine_id,
1316
+ COUNT(DISTINCT s.id) as sessions,
1317
+ COALESCE((SELECT COUNT(*) FROM requests r WHERE r.machine_id = s.machine_id), 0) as requests,
1318
+ COALESCE(SUM(s.total_cost_usd), 0) as total_cost_usd,
1319
+ MAX(s.started_at) as last_active
1320
+ FROM sessions s
1321
+ WHERE s.machine_id != ''
1322
+ GROUP BY s.machine_id
1323
+ ORDER BY total_cost_usd DESC
1324
+ `).all();
1325
+ }
502
1326
  function upsertModelPricing(db, p) {
503
1327
  db.prepare(`
504
1328
  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);
1329
+ (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)
1330
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1331
+ `).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
1332
  }
509
1333
  function getModelPricing(db, model) {
510
1334
  return db.prepare(`SELECT * FROM model_pricing WHERE model = ?`).get(model);
@@ -516,83 +1340,971 @@ function deleteModelPricing(db, model) {
516
1340
  db.prepare(`DELETE FROM model_pricing WHERE model = ?`).run(model);
517
1341
  }
518
1342
  function seedModelPricing(db, defaults) {
519
- const existing = db.prepare(`SELECT COUNT(*) as count FROM model_pricing`).get();
520
- if (existing.count > 0)
521
- return;
1343
+ const existing = new Set(db.prepare(`SELECT model FROM model_pricing`).all().map((r) => r.model));
522
1344
  const now = new Date().toISOString();
523
1345
  for (const [model, p] of Object.entries(defaults)) {
1346
+ if (existing.has(model))
1347
+ continue;
524
1348
  upsertModelPricing(db, {
525
1349
  model,
526
1350
  input_per_1m: p.inputPer1M,
527
1351
  output_per_1m: p.outputPer1M,
528
1352
  cache_read_per_1m: p.cacheReadPer1M,
529
1353
  cache_write_per_1m: p.cacheWritePer1M,
1354
+ cache_write_1h_per_1m: p.cacheWrite1hPer1M ?? 0,
1355
+ cache_storage_per_1m_hour: p.cacheStoragePer1MHour ?? 0,
530
1356
  updated_at: now
531
1357
  });
532
1358
  }
533
1359
  }
1360
+ function upsertSubscription(db, sub) {
1361
+ db.prepare(`
1362
+ INSERT OR REPLACE INTO subscriptions
1363
+ (id, agent, provider, plan, monthly_fee_usd, included_usage_usd, billing_cycle_start, reset_policy, active, created_at, updated_at)
1364
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1365
+ `).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);
1366
+ }
1367
+ function upsertUsageSnapshot(db, snap) {
1368
+ const now = snap.updated_at ?? new Date().toISOString();
1369
+ const id = snap.id ?? `${snap.agent}-${snap.date}-${snap.metric}-${snap.machine_id}`;
1370
+ db.prepare(`
1371
+ INSERT OR REPLACE INTO usage_snapshots (id, agent, date, metric, value, unit, machine_id, updated_at)
1372
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1373
+ `).run(id, snap.agent, snap.date, snap.metric, snap.value, snap.unit, snap.machine_id, now);
1374
+ }
1375
+ function queryUsageSnapshots(db, opts = {}) {
1376
+ const conditions = [];
1377
+ const params = [];
1378
+ if (opts.agent) {
1379
+ conditions.push("agent = ?");
1380
+ params.push(opts.agent);
1381
+ }
1382
+ if (opts.date) {
1383
+ conditions.push("date = ?");
1384
+ params.push(opts.date);
1385
+ }
1386
+ if (opts.since) {
1387
+ conditions.push("date >= ?");
1388
+ params.push(opts.since);
1389
+ }
1390
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
1391
+ return db.prepare(`SELECT * FROM usage_snapshots ${where} ORDER BY date DESC, agent, metric`).all(...params);
1392
+ }
1393
+ function listMachineRegistry(db) {
1394
+ return db.prepare(`SELECT * FROM machines ORDER BY last_seen_at DESC`).all();
1395
+ }
1396
+ function dedupeRequests(db) {
1397
+ const dupes = db.prepare(`
1398
+ SELECT source_request_id, agent, MIN(id) as keep_id, COUNT(*) as cnt
1399
+ FROM requests
1400
+ WHERE source_request_id != '' AND source_request_id IS NOT NULL
1401
+ GROUP BY source_request_id, agent
1402
+ HAVING cnt > 1
1403
+ `).all();
1404
+ let removed = 0;
1405
+ for (const row of dupes) {
1406
+ const result = db.prepare(`
1407
+ DELETE FROM requests WHERE source_request_id = ? AND agent = ? AND id != ?
1408
+ `).run(row.source_request_id, row.agent, row.keep_id);
1409
+ removed += result.changes;
1410
+ }
1411
+ return removed;
1412
+ }
534
1413
  var init_database = () => {};
535
1414
 
536
- // src/server/serve.ts
537
- init_database();
1415
+ // src/db/pg-migrations.ts
1416
+ var exports_pg_migrations = {};
1417
+ __export(exports_pg_migrations, {
1418
+ PG_MIGRATIONS: () => PG_MIGRATIONS
1419
+ });
1420
+ var PG_MIGRATIONS;
1421
+ var init_pg_migrations = __esm(() => {
1422
+ PG_MIGRATIONS = [
1423
+ `CREATE TABLE IF NOT EXISTS requests (
1424
+ id TEXT PRIMARY KEY,
1425
+ agent TEXT NOT NULL,
1426
+ session_id TEXT NOT NULL,
1427
+ model TEXT NOT NULL,
1428
+ input_tokens INTEGER DEFAULT 0,
1429
+ output_tokens INTEGER DEFAULT 0,
1430
+ cache_read_tokens INTEGER DEFAULT 0,
1431
+ cache_create_tokens INTEGER DEFAULT 0,
1432
+ cache_create_5m_tokens INTEGER DEFAULT 0,
1433
+ cache_create_1h_tokens INTEGER DEFAULT 0,
1434
+ cost_usd REAL NOT NULL DEFAULT 0,
1435
+ duration_ms INTEGER DEFAULT 0,
1436
+ timestamp TEXT NOT NULL,
1437
+ source_request_id TEXT,
1438
+ machine_id TEXT DEFAULT '',
1439
+ account_key TEXT DEFAULT '',
1440
+ account_tool TEXT DEFAULT '',
1441
+ account_name TEXT DEFAULT '',
1442
+ account_email TEXT DEFAULT '',
1443
+ account_source TEXT DEFAULT ''
1444
+ )`,
1445
+ `CREATE TABLE IF NOT EXISTS sessions (
1446
+ id TEXT PRIMARY KEY,
1447
+ agent TEXT NOT NULL,
1448
+ project_path TEXT DEFAULT '',
1449
+ project_name TEXT DEFAULT '',
1450
+ started_at TEXT NOT NULL,
1451
+ ended_at TEXT,
1452
+ total_cost_usd REAL DEFAULT 0,
1453
+ total_tokens INTEGER DEFAULT 0,
1454
+ request_count INTEGER DEFAULT 0,
1455
+ machine_id TEXT DEFAULT '',
1456
+ account_key TEXT DEFAULT '',
1457
+ account_tool TEXT DEFAULT '',
1458
+ account_name TEXT DEFAULT '',
1459
+ account_email TEXT DEFAULT '',
1460
+ account_source TEXT DEFAULT ''
1461
+ )`,
1462
+ `CREATE TABLE IF NOT EXISTS projects (
1463
+ id TEXT PRIMARY KEY,
1464
+ path TEXT UNIQUE NOT NULL,
1465
+ name TEXT NOT NULL,
1466
+ description TEXT,
1467
+ tags TEXT DEFAULT '[]',
1468
+ created_at TEXT NOT NULL
1469
+ )`,
1470
+ `CREATE TABLE IF NOT EXISTS budgets (
1471
+ id TEXT PRIMARY KEY,
1472
+ project_path TEXT,
1473
+ agent TEXT,
1474
+ period TEXT NOT NULL,
1475
+ limit_usd REAL NOT NULL,
1476
+ alert_at_percent INTEGER DEFAULT 80,
1477
+ created_at TEXT NOT NULL,
1478
+ updated_at TEXT NOT NULL
1479
+ )`,
1480
+ `CREATE TABLE IF NOT EXISTS goals (
1481
+ id TEXT PRIMARY KEY,
1482
+ period TEXT NOT NULL,
1483
+ project_path TEXT,
1484
+ agent TEXT,
1485
+ limit_usd REAL NOT NULL,
1486
+ created_at TEXT NOT NULL,
1487
+ updated_at TEXT NOT NULL
1488
+ )`,
1489
+ `CREATE TABLE IF NOT EXISTS ingest_state (
1490
+ source TEXT NOT NULL,
1491
+ key TEXT NOT NULL,
1492
+ value TEXT NOT NULL,
1493
+ PRIMARY KEY (source, key)
1494
+ )`,
1495
+ `CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)`,
1496
+ `CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp)`,
1497
+ `CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent)`,
1498
+ `CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id)`,
1499
+ `CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`,
1500
+ `CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
1501
+ `CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
1502
+ `CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
1503
+ `CREATE TABLE IF NOT EXISTS model_pricing (
1504
+ model TEXT PRIMARY KEY,
1505
+ input_per_1m REAL NOT NULL DEFAULT 0,
1506
+ output_per_1m REAL NOT NULL DEFAULT 0,
1507
+ cache_read_per_1m REAL NOT NULL DEFAULT 0,
1508
+ cache_write_per_1m REAL NOT NULL DEFAULT 0,
1509
+ cache_write_1h_per_1m REAL NOT NULL DEFAULT 0,
1510
+ cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0,
1511
+ updated_at TEXT NOT NULL
1512
+ )`,
1513
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS cache_create_5m_tokens INTEGER DEFAULT 0`,
1514
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS cache_create_1h_tokens INTEGER DEFAULT 0`,
1515
+ `ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`,
1516
+ `ALTER TABLE model_pricing ADD COLUMN IF NOT EXISTS cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`,
1517
+ `CREATE TABLE IF NOT EXISTS feedback (
1518
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
1519
+ message TEXT NOT NULL,
1520
+ email TEXT,
1521
+ category TEXT DEFAULT 'general',
1522
+ version TEXT,
1523
+ machine_id TEXT,
1524
+ created_at TEXT NOT NULL DEFAULT NOW()::text
1525
+ )`,
1526
+ `CREATE TABLE IF NOT EXISTS billing_daily (
1527
+ date TEXT NOT NULL,
1528
+ provider TEXT NOT NULL,
1529
+ description TEXT DEFAULT '',
1530
+ cost_usd REAL NOT NULL DEFAULT 0,
1531
+ updated_at TEXT NOT NULL,
1532
+ PRIMARY KEY (date, provider, description)
1533
+ )`,
1534
+ `CREATE INDEX IF NOT EXISTS idx_billing_date ON billing_daily(date)`,
1535
+ `CREATE INDEX IF NOT EXISTS idx_billing_provider ON billing_daily(provider)`,
1536
+ `CREATE TABLE IF NOT EXISTS subscriptions (
1537
+ id TEXT PRIMARY KEY,
1538
+ agent TEXT,
1539
+ provider TEXT NOT NULL,
1540
+ plan TEXT NOT NULL,
1541
+ monthly_fee_usd REAL NOT NULL DEFAULT 0,
1542
+ included_usage_usd REAL NOT NULL DEFAULT 0,
1543
+ billing_cycle_start TEXT,
1544
+ reset_policy TEXT DEFAULT 'monthly',
1545
+ active INTEGER NOT NULL DEFAULT 1,
1546
+ created_at TEXT NOT NULL,
1547
+ updated_at TEXT NOT NULL
1548
+ )`,
1549
+ `CREATE TABLE IF NOT EXISTS usage_snapshots (
1550
+ id TEXT PRIMARY KEY,
1551
+ agent TEXT NOT NULL,
1552
+ date TEXT NOT NULL,
1553
+ metric TEXT NOT NULL,
1554
+ value REAL NOT NULL DEFAULT 0,
1555
+ unit TEXT DEFAULT '',
1556
+ machine_id TEXT DEFAULT '',
1557
+ updated_at TEXT NOT NULL
1558
+ )`,
1559
+ `CREATE TABLE IF NOT EXISTS savings_daily (
1560
+ date TEXT NOT NULL,
1561
+ agent TEXT DEFAULT '',
1562
+ api_equivalent_usd REAL NOT NULL DEFAULT 0,
1563
+ subscription_fee_usd REAL NOT NULL DEFAULT 0,
1564
+ included_consumed_usd REAL NOT NULL DEFAULT 0,
1565
+ on_demand_usd REAL NOT NULL DEFAULT 0,
1566
+ saved_usd REAL NOT NULL DEFAULT 0,
1567
+ updated_at TEXT NOT NULL,
1568
+ PRIMARY KEY (date, agent)
1569
+ )`,
1570
+ `CREATE TABLE IF NOT EXISTS machines (
1571
+ machine_id TEXT PRIMARY KEY,
1572
+ hostname TEXT NOT NULL,
1573
+ last_seen_at TEXT,
1574
+ last_push_at TEXT,
1575
+ last_pull_at TEXT,
1576
+ economy_version TEXT,
1577
+ updated_at TEXT NOT NULL
1578
+ )`,
1579
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS cost_basis TEXT DEFAULT 'estimated'`,
1580
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
1581
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
1582
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
1583
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_name TEXT DEFAULT ''`,
1584
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_email TEXT DEFAULT ''`,
1585
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
1586
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
1587
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
1588
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
1589
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
1590
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
1591
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_name TEXT DEFAULT ''`,
1592
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_email TEXT DEFAULT ''`,
1593
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
1594
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
1595
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
1596
+ `CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date)`,
1597
+ `CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`,
1598
+ `CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key)`,
1599
+ `CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key)`
1600
+ ];
1601
+ });
538
1602
 
539
- // src/ingest/claude.ts
540
- init_database();
541
- init_pricing();
542
- import { readdirSync as readdirSync2, readFileSync, existsSync as existsSync2, statSync as statSync2 } from "fs";
543
- import { homedir as homedir2 } from "os";
544
- import { join as join2, basename } from "path";
545
- function autoDetectProject(cwd, projects) {
546
- return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
1603
+ // src/ingest/billing.ts
1604
+ var exports_billing = {};
1605
+ __export(exports_billing, {
1606
+ syncOpenAIBilling: () => syncOpenAIBilling,
1607
+ syncGeminiBilling: () => syncGeminiBilling,
1608
+ syncAnthropicBilling: () => syncAnthropicBilling
1609
+ });
1610
+ import { readFileSync as readFileSync9 } from "fs";
1611
+ function getAnthropicAdminKey() {
1612
+ return process.env["HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY"] ?? process.env["ANTHROPIC_ADMIN_API_KEY"] ?? null;
547
1613
  }
548
- var PROJECTS_DIR = join2(homedir2(), ".claude", "projects");
549
- function dirNameToPath(dirName) {
550
- return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
1614
+ function getOpenAIAdminKey() {
1615
+ return process.env["HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY"] ?? process.env["OPENAI_ADMIN_API_KEY"] ?? null;
551
1616
  }
552
- function collectJsonlFiles(projectDir) {
553
- const files = [];
554
- function walk(dir) {
555
- try {
556
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
557
- if (entry.isDirectory())
558
- walk(join2(dir, entry.name));
559
- else if (entry.name.endsWith(".jsonl"))
560
- files.push(join2(dir, entry.name));
1617
+ function getGeminiBillingExportPath() {
1618
+ return process.env["HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH"] ?? process.env["HASNAXYZ_ECONOMY_GEMINI_BILLING_EXPORT_PATH"] ?? process.env["GEMINI_BILLING_EXPORT_PATH"] ?? null;
1619
+ }
1620
+ function toISODate(d) {
1621
+ return d.toISOString().substring(0, 10);
1622
+ }
1623
+ function parseDate(value) {
1624
+ if (typeof value !== "string" || !value.trim())
1625
+ return null;
1626
+ const d = new Date(value);
1627
+ if (Number.isNaN(d.getTime()))
1628
+ return value.substring(0, 10);
1629
+ return toISODate(d);
1630
+ }
1631
+ function parseCsv(content) {
1632
+ const lines = content.split(/\r?\n/).filter((line) => line.trim());
1633
+ if (lines.length < 2)
1634
+ return [];
1635
+ const headers = parseCsvLine(lines[0]).map((h) => h.trim());
1636
+ return lines.slice(1).map((line) => {
1637
+ const values = parseCsvLine(line);
1638
+ return Object.fromEntries(headers.map((header, i) => [header, values[i]?.trim() ?? ""]));
1639
+ });
1640
+ }
1641
+ function parseCsvLine(line) {
1642
+ const values = [];
1643
+ let value = "";
1644
+ let quoted = false;
1645
+ for (let i = 0;i < line.length; i++) {
1646
+ const char = line[i];
1647
+ if (char === '"') {
1648
+ if (quoted && line[i + 1] === '"') {
1649
+ value += '"';
1650
+ i++;
1651
+ } else {
1652
+ quoted = !quoted;
561
1653
  }
562
- } catch {}
1654
+ } else if (char === "," && !quoted) {
1655
+ values.push(value);
1656
+ value = "";
1657
+ } else {
1658
+ value += char;
1659
+ }
563
1660
  }
564
- walk(projectDir);
565
- return files;
1661
+ values.push(value);
1662
+ return values;
566
1663
  }
567
- async function ingestClaude(db, verbose = false, _telemetryDir) {
568
- if (!existsSync2(PROJECTS_DIR)) {
569
- if (verbose)
570
- console.log("Claude projects dir not found:", PROJECTS_DIR);
571
- return { files: 0, requests: 0, sessions: 0 };
1664
+ function parseBillingRows(content) {
1665
+ const trimmed = content.trim();
1666
+ if (!trimmed)
1667
+ return [];
1668
+ try {
1669
+ const parsed = JSON.parse(trimmed);
1670
+ if (Array.isArray(parsed))
1671
+ return parsed;
1672
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed["rows"])) {
1673
+ return parsed["rows"];
1674
+ }
1675
+ } catch {}
1676
+ const jsonlRows = [];
1677
+ for (const line of trimmed.split(/\r?\n/)) {
1678
+ try {
1679
+ const parsed = JSON.parse(line);
1680
+ if (parsed && typeof parsed === "object")
1681
+ jsonlRows.push(parsed);
1682
+ } catch {
1683
+ jsonlRows.length = 0;
1684
+ break;
1685
+ }
572
1686
  }
573
- let totalFiles = 0;
574
- let totalRequests = 0;
575
- const touchedSessions = new Set;
576
- const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
577
- const projectDirs = readdirSync2(PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
578
- for (const projectDirEntry of projectDirs) {
579
- const projectDirPath = join2(PROJECTS_DIR, projectDirEntry.name);
580
- const projectPath = dirNameToPath(projectDirEntry.name);
581
- const jsonlFiles = collectJsonlFiles(projectDirPath);
582
- for (const filePath of jsonlFiles) {
583
- const stateKey = filePath.replace(PROJECTS_DIR, "");
584
- let fileMtime = "0";
585
- try {
586
- fileMtime = statSync2(filePath).mtimeMs.toString();
587
- } catch {
1687
+ if (jsonlRows.length > 0)
1688
+ return jsonlRows;
1689
+ return parseCsv(content);
1690
+ }
1691
+ async function syncAnthropicBilling(db, opts = {}) {
1692
+ const key = getAnthropicAdminKey();
1693
+ if (!key)
1694
+ throw new Error("Missing Anthropic admin key (HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY)");
1695
+ const now = new Date;
1696
+ const end = opts.toDate ? new Date(opts.toDate) : new Date(now.getTime() + 24 * 3600000);
1697
+ const days = opts.days ?? 31;
1698
+ const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
1699
+ const startIso = start.toISOString().replace(/\.\d+/, "").replace(/:\d{2}Z$/, ":00Z");
1700
+ const endIso = end.toISOString().replace(/\.\d+/, "").replace(/:\d{2}Z$/, ":00Z");
1701
+ let totalUsd = 0;
1702
+ const buckets = [];
1703
+ let nextPage;
1704
+ do {
1705
+ const url = new URL("https://api.anthropic.com/v1/organizations/cost_report");
1706
+ url.searchParams.set("starting_at", startIso);
1707
+ url.searchParams.set("ending_at", endIso);
1708
+ url.searchParams.set("bucket_width", "1d");
1709
+ url.searchParams.set("limit", "31");
1710
+ url.searchParams.append("group_by[]", "description");
1711
+ if (nextPage)
1712
+ url.searchParams.set("page", nextPage);
1713
+ const res = await fetch(url.toString(), {
1714
+ headers: { "anthropic-version": "2023-06-01", "x-api-key": key }
1715
+ });
1716
+ const data = await res.json();
1717
+ if (data.error)
1718
+ throw new Error(`Anthropic API: ${data.error.message}`);
1719
+ if (data.data)
1720
+ buckets.push(...data.data);
1721
+ nextPage = data.has_more ? data.next_page : undefined;
1722
+ } while (nextPage);
1723
+ const fromDateStr = toISODate(start);
1724
+ const toDateStr = toISODate(new Date(end.getTime() - 1000));
1725
+ clearBillingRange(db, "anthropic", fromDateStr, toDateStr);
1726
+ const updatedAt = new Date().toISOString();
1727
+ for (const bucket of buckets) {
1728
+ const date = bucket.starting_at.substring(0, 10);
1729
+ for (const r of bucket.results) {
1730
+ const usd = Number(r.amount) / 100;
1731
+ if (usd === 0)
588
1732
  continue;
589
- }
590
- const processed = getIngestState(db, "claude", stateKey);
591
- if (processed === fileMtime)
1733
+ const desc = (r.description ?? "unknown").substring(0, 200);
1734
+ upsertBillingDaily(db, { date, provider: "anthropic", description: desc, cost_usd: usd, updated_at: updatedAt });
1735
+ totalUsd += usd;
1736
+ }
1737
+ }
1738
+ return { days: buckets.length, totalUsd };
1739
+ }
1740
+ async function syncOpenAIBilling(db, opts = {}) {
1741
+ const key = getOpenAIAdminKey();
1742
+ if (!key)
1743
+ throw new Error("Missing OpenAI admin key (HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY)");
1744
+ const now = new Date;
1745
+ const end = opts.toDate ? new Date(opts.toDate) : now;
1746
+ const days = opts.days ?? 31;
1747
+ const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
1748
+ const startSec = Math.floor(start.getTime() / 1000);
1749
+ const endSec = Math.floor(end.getTime() / 1000);
1750
+ let totalUsd = 0;
1751
+ const buckets = [];
1752
+ let nextPage;
1753
+ do {
1754
+ const url = new URL("https://api.openai.com/v1/organization/costs");
1755
+ url.searchParams.set("start_time", String(startSec));
1756
+ url.searchParams.set("end_time", String(endSec));
1757
+ url.searchParams.set("bucket_width", "1d");
1758
+ url.searchParams.set("limit", "31");
1759
+ url.searchParams.append("group_by[]", "line_item");
1760
+ if (nextPage)
1761
+ url.searchParams.set("page", nextPage);
1762
+ const res = await fetch(url.toString(), {
1763
+ headers: { Authorization: `Bearer ${key}` }
1764
+ });
1765
+ const data = await res.json();
1766
+ if (data.error)
1767
+ throw new Error(`OpenAI API: ${data.error.message}`);
1768
+ if (data.data)
1769
+ buckets.push(...data.data);
1770
+ nextPage = data.has_more ? data.next_page : undefined;
1771
+ } while (nextPage);
1772
+ const fromDateStr = toISODate(start);
1773
+ const toDateStr = toISODate(new Date(end.getTime() - 1000));
1774
+ clearBillingRange(db, "openai", fromDateStr, toDateStr);
1775
+ const updatedAt = new Date().toISOString();
1776
+ for (const bucket of buckets) {
1777
+ const date = new Date(bucket.start_time * 1000).toISOString().substring(0, 10);
1778
+ for (const r of bucket.results) {
1779
+ const usd = Number(r.amount?.value ?? 0);
1780
+ if (usd === 0)
592
1781
  continue;
593
- let lines;
594
- try {
595
- lines = readFileSync(filePath, "utf-8").split(`
1782
+ const desc = (r.line_item ?? "unknown").substring(0, 200);
1783
+ upsertBillingDaily(db, { date, provider: "openai", description: desc, cost_usd: usd, updated_at: updatedAt });
1784
+ totalUsd += usd;
1785
+ }
1786
+ }
1787
+ return { days: buckets.length, totalUsd };
1788
+ }
1789
+ async function syncGeminiBilling(db, opts = {}) {
1790
+ const exportPath = getGeminiBillingExportPath();
1791
+ if (!exportPath) {
1792
+ return {
1793
+ days: 0,
1794
+ totalUsd: 0,
1795
+ skipped: "Missing Gemini billing export path (HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH, HASNAXYZ_ECONOMY_GEMINI_BILLING_EXPORT_PATH, or GEMINI_BILLING_EXPORT_PATH)"
1796
+ };
1797
+ }
1798
+ const now = new Date;
1799
+ const end = opts.toDate ? new Date(opts.toDate) : now;
1800
+ const days = opts.days ?? 31;
1801
+ const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
1802
+ const fromDateStr = toISODate(start);
1803
+ const toDateStr = toISODate(end);
1804
+ const rows = parseBillingRows(readFileSync9(exportPath, "utf-8"));
1805
+ clearBillingRange(db, "gemini", fromDateStr, toDateStr);
1806
+ const updatedAt = new Date().toISOString();
1807
+ let totalUsd = 0;
1808
+ const seenDays = new Set;
1809
+ for (const row of rows) {
1810
+ const date = parseDate(row["date"] ?? row["usage_start_time"] ?? row["start_time"] ?? row["invoice.month"]);
1811
+ if (!date || date < fromDateStr || date > toDateStr)
1812
+ continue;
1813
+ const rawCost = row["cost_usd"] ?? row["costUsd"] ?? row["cost"] ?? row["amount"];
1814
+ const costUsd = Number(rawCost);
1815
+ if (!Number.isFinite(costUsd) || costUsd === 0)
1816
+ continue;
1817
+ const service = row["service.description"] ?? row["service"] ?? row["provider"] ?? "";
1818
+ const sku = row["sku.description"] ?? row["sku"] ?? row["description"] ?? "Gemini API";
1819
+ const description = `${String(service || "Google AI")}: ${String(sku)}`.substring(0, 200);
1820
+ upsertBillingDaily(db, { date, provider: "gemini", description, cost_usd: costUsd, updated_at: updatedAt });
1821
+ totalUsd += costUsd;
1822
+ seenDays.add(date);
1823
+ }
1824
+ return { days: seenDays.size, totalUsd };
1825
+ }
1826
+ var init_billing = __esm(() => {
1827
+ init_database();
1828
+ });
1829
+
1830
+ // src/lib/open-projects.ts
1831
+ var exports_open_projects = {};
1832
+ __export(exports_open_projects, {
1833
+ syncOpenProjectsRegistry: () => syncOpenProjectsRegistry
1834
+ });
1835
+ async function syncOpenProjectsRegistry(db, listActiveProjects) {
1836
+ let listProjects2 = listActiveProjects;
1837
+ if (!listProjects2) {
1838
+ const projectsApi = await import("@hasna/projects");
1839
+ listProjects2 = projectsApi.listProjects;
1840
+ }
1841
+ const projects = listProjects2({ status: "active", limit: 5000 });
1842
+ let imported = 0;
1843
+ let skipped = 0;
1844
+ for (const project of projects) {
1845
+ if (!project.path) {
1846
+ skipped++;
1847
+ continue;
1848
+ }
1849
+ upsertProject(db, {
1850
+ id: project.id,
1851
+ path: project.path,
1852
+ name: project.name,
1853
+ description: project.description,
1854
+ tags: project.tags ?? [],
1855
+ created_at: project.created_at
1856
+ });
1857
+ imported++;
1858
+ }
1859
+ return { imported, skipped };
1860
+ }
1861
+ var init_open_projects = __esm(() => {
1862
+ init_database();
1863
+ });
1864
+
1865
+ // src/lib/config.ts
1866
+ import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
1867
+ import { dirname, join as join9 } from "path";
1868
+ function getConfigPath() {
1869
+ return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join9(getDataDir(), "config.json");
1870
+ }
1871
+ function loadConfig() {
1872
+ try {
1873
+ const configPath = getConfigPath();
1874
+ if (existsSync10(configPath)) {
1875
+ const raw = readFileSync10(configPath, "utf-8");
1876
+ return { ...DEFAULTS, ...JSON.parse(raw) };
1877
+ }
1878
+ } catch {}
1879
+ return { ...DEFAULTS };
1880
+ }
1881
+ var DEFAULTS;
1882
+ var init_config = __esm(() => {
1883
+ init_database();
1884
+ DEFAULTS = {
1885
+ port: 3456,
1886
+ "default-period": "today",
1887
+ "auto-sync": true,
1888
+ "sync-interval": 30,
1889
+ "alert-thresholds": [5, 10, 25, 50, 100],
1890
+ "webhook-url": null
1891
+ };
1892
+ });
1893
+
1894
+ // src/lib/spikes.ts
1895
+ function detectCostSpikes(dailyTotals, opts) {
1896
+ const windowDays = opts?.windowDays ?? 7;
1897
+ const multiplier = opts?.multiplier ?? 2;
1898
+ const sorted = [...dailyTotals].sort((a, b) => a.date.localeCompare(b.date));
1899
+ const spikes = [];
1900
+ for (let i = windowDays;i < sorted.length; i++) {
1901
+ const window = sorted.slice(i - windowDays, i);
1902
+ const avg = window.reduce((s, d) => s + d.cost_usd, 0) / window.length;
1903
+ const current = sorted[i];
1904
+ if (avg > 0 && current.cost_usd > avg * multiplier) {
1905
+ spikes.push({
1906
+ date: current.date,
1907
+ cost_usd: current.cost_usd,
1908
+ average_usd: avg,
1909
+ ratio: current.cost_usd / avg
1910
+ });
1911
+ }
1912
+ }
1913
+ return spikes;
1914
+ }
1915
+ function queryRecentSpikes(db, days = 14) {
1916
+ const rows = db.prepare(`
1917
+ SELECT DATE(timestamp) as date, COALESCE(SUM(cost_usd), 0) as cost_usd
1918
+ FROM requests
1919
+ WHERE timestamp >= DATE('now', '-' || ? || ' days')
1920
+ GROUP BY DATE(timestamp)
1921
+ ORDER BY date ASC
1922
+ `).all(days);
1923
+ return detectCostSpikes(rows);
1924
+ }
1925
+ function getTodaySpike(db) {
1926
+ const today = new Date().toISOString().substring(0, 10);
1927
+ const spikes = queryRecentSpikes(db, 14);
1928
+ return spikes.find((s) => s.date === today) ?? null;
1929
+ }
1930
+
1931
+ // src/lib/webhooks.ts
1932
+ var exports_webhooks = {};
1933
+ __export(exports_webhooks, {
1934
+ checkAndFireWebhooks: () => checkAndFireWebhooks
1935
+ });
1936
+ async function checkAndFireWebhooks(db) {
1937
+ const config = loadConfig();
1938
+ const url = config["webhook-url"];
1939
+ if (!url)
1940
+ return;
1941
+ const statuses = getBudgetStatuses(db);
1942
+ for (const b of statuses) {
1943
+ if (!b.is_over_alert)
1944
+ continue;
1945
+ const key = `webhook-budget-${b.id}-${b.period}`;
1946
+ const lastFired = getIngestState(db, "webhook", key);
1947
+ const pctBucket = Math.floor(b.percent_used / 10) * 10;
1948
+ if (lastFired === String(pctBucket))
1949
+ continue;
1950
+ const delivered = await fireWebhook(url, {
1951
+ event: "budget_alert",
1952
+ budget_id: b.id,
1953
+ project: b.project_path ?? "global",
1954
+ period: b.period,
1955
+ spend: b.current_spend_usd,
1956
+ limit: b.limit_usd,
1957
+ percent: Math.round(b.percent_used * 10) / 10
1958
+ });
1959
+ if (delivered)
1960
+ setIngestState(db, "webhook", key, String(pctBucket));
1961
+ }
1962
+ const spike = getTodaySpike(db);
1963
+ if (spike) {
1964
+ const key = `webhook-spike-${spike.date}`;
1965
+ if (getIngestState(db, "webhook", key) !== "1") {
1966
+ const delivered = await fireWebhook(url, {
1967
+ event: "cost_spike",
1968
+ date: spike.date,
1969
+ cost_usd: spike.cost_usd,
1970
+ average_usd: spike.average_usd,
1971
+ ratio: Math.round(spike.ratio * 100) / 100
1972
+ });
1973
+ if (delivered)
1974
+ setIngestState(db, "webhook", key, "1");
1975
+ }
1976
+ }
1977
+ }
1978
+ async function fireWebhook(url, payload) {
1979
+ try {
1980
+ const response = await fetch(url, {
1981
+ method: "POST",
1982
+ headers: { "Content-Type": "application/json" },
1983
+ body: JSON.stringify(payload),
1984
+ signal: AbortSignal.timeout(5000)
1985
+ });
1986
+ return response.ok;
1987
+ } catch {
1988
+ return false;
1989
+ }
1990
+ }
1991
+ var init_webhooks = __esm(() => {
1992
+ init_config();
1993
+ init_database();
1994
+ });
1995
+
1996
+ // src/server/serve.ts
1997
+ init_database();
1998
+ init_pricing();
1999
+
2000
+ // src/lib/agents.ts
2001
+ var AGENTS = [
2002
+ "claude",
2003
+ "takumi",
2004
+ "codex",
2005
+ "gemini",
2006
+ "opencode",
2007
+ "cursor",
2008
+ "pi",
2009
+ "hermes"
2010
+ ];
2011
+ function isAgent(value) {
2012
+ return AGENTS.includes(value);
2013
+ }
2014
+
2015
+ // src/ingest/claude.ts
2016
+ init_database();
2017
+ init_pricing();
2018
+ import { readdirSync as readdirSync2, readFileSync, existsSync as existsSync2, statSync as statSync2 } from "fs";
2019
+ import { homedir as homedir2 } from "os";
2020
+ import { join as join2, basename } from "path";
2021
+
2022
+ // src/lib/savings.ts
2023
+ function periodWhere2(period, column) {
2024
+ switch (period) {
2025
+ case "today":
2026
+ return `DATE(${column}) = DATE('now')`;
2027
+ case "yesterday":
2028
+ return `DATE(${column}) = DATE('now', '-1 day')`;
2029
+ case "week":
2030
+ return `${column} >= DATE('now', 'weekday 0', '-7 days')`;
2031
+ case "month":
2032
+ return `${column} >= DATE('now', 'start of month')`;
2033
+ case "year":
2034
+ return `${column} >= DATE('now', 'start of year')`;
2035
+ case "all":
2036
+ return "1=1";
2037
+ }
2038
+ }
2039
+ function prorateMonthlyFee(monthlyFee, period) {
2040
+ const now = new Date;
2041
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
2042
+ switch (period) {
2043
+ case "today":
2044
+ case "yesterday":
2045
+ return monthlyFee / daysInMonth;
2046
+ case "week":
2047
+ return monthlyFee / daysInMonth * 7;
2048
+ case "month":
2049
+ return monthlyFee;
2050
+ case "year":
2051
+ return monthlyFee * 12;
2052
+ case "all":
2053
+ return monthlyFee;
2054
+ }
2055
+ }
2056
+ function computeSavedUsd(apiEquivalent, onDemand, subscriptionFee) {
2057
+ return Math.max(0, apiEquivalent - onDemand - subscriptionFee);
2058
+ }
2059
+ function querySavingsSummary(db, period, agent) {
2060
+ const where = periodWhere2(period, "timestamp");
2061
+ const agentClause = agent ? " AND agent = ?" : "";
2062
+ const params = agent ? [agent] : [];
2063
+ const apiRow = db.prepare(`
2064
+ SELECT COALESCE(SUM(cost_usd), 0) as total
2065
+ FROM requests
2066
+ WHERE ${where}${agentClause}
2067
+ AND COALESCE(cost_basis, 'estimated') IN ('metered_api', 'estimated', 'unknown')
2068
+ `).get(...params);
2069
+ const includedRow = db.prepare(`
2070
+ SELECT COALESCE(SUM(cost_usd), 0) as total
2071
+ FROM requests
2072
+ WHERE ${where}${agentClause}
2073
+ AND cost_basis = 'subscription_included'
2074
+ `).get(...params);
2075
+ const subWhere = periodWhere2(period, "date");
2076
+ const onDemandRow = db.prepare(`
2077
+ SELECT COALESCE(SUM(value), 0) as total
2078
+ FROM usage_snapshots
2079
+ WHERE ${subWhere}${agent ? " AND agent = ?" : ""}
2080
+ AND metric = 'on_demand_usd'
2081
+ `).get(...params);
2082
+ const subs = db.prepare(`
2083
+ SELECT COALESCE(SUM(monthly_fee_usd), 0) as total
2084
+ FROM subscriptions
2085
+ WHERE active = 1${agent ? " AND agent = ?" : ""}
2086
+ `).get(...agent ? [agent] : []);
2087
+ const subscriptionFee = prorateMonthlyFee(subs.total, period);
2088
+ const apiEquivalent = apiRow.total + includedRow.total;
2089
+ const onDemand = onDemandRow.total;
2090
+ const saved = computeSavedUsd(apiEquivalent, onDemand, subscriptionFee);
2091
+ const byAgent = {};
2092
+ if (!agent) {
2093
+ for (const row of db.prepare(`
2094
+ SELECT agent, COALESCE(SUM(cost_usd), 0) as api_eq
2095
+ FROM requests WHERE ${where}
2096
+ GROUP BY agent
2097
+ `).all()) {
2098
+ byAgent[row.agent] = {
2099
+ api_equivalent_usd: row.api_eq,
2100
+ saved_usd: row.api_eq
2101
+ };
2102
+ }
2103
+ }
2104
+ return {
2105
+ period,
2106
+ api_equivalent_usd: apiEquivalent,
2107
+ subscription_fee_usd: subscriptionFee,
2108
+ included_consumed_usd: includedRow.total,
2109
+ on_demand_usd: onDemand,
2110
+ saved_usd: saved,
2111
+ by_agent: byAgent
2112
+ };
2113
+ }
2114
+ function defaultCostBasisForAgent(agent) {
2115
+ if (agent === "claude")
2116
+ return "metered_api";
2117
+ if (agent === "cursor")
2118
+ return "subscription_included";
2119
+ return "estimated";
2120
+ }
2121
+
2122
+ // src/lib/accounts.ts
2123
+ var AGENT_ACCOUNT_TOOLS = {
2124
+ claude: ["claude"],
2125
+ takumi: ["takumi", "claude"],
2126
+ codex: ["codex"],
2127
+ gemini: ["gemini"],
2128
+ opencode: ["opencode"],
2129
+ cursor: ["cursor"],
2130
+ pi: ["pi"],
2131
+ hermes: ["hermes"]
2132
+ };
2133
+ function accountKey(tool, name) {
2134
+ return `${tool}:${name}`;
2135
+ }
2136
+ function normalizeDir(value) {
2137
+ return value.replace(/\/+$/, "");
2138
+ }
2139
+ function fromProfile(profile, source) {
2140
+ return {
2141
+ account_key: accountKey(profile.tool, profile.name),
2142
+ account_tool: profile.tool,
2143
+ account_name: profile.name,
2144
+ ...profile.email ? { account_email: profile.email } : {},
2145
+ account_source: source
2146
+ };
2147
+ }
2148
+ function fromOverride(raw, agent) {
2149
+ const value = raw.trim();
2150
+ if (!value)
2151
+ return null;
2152
+ const candidateTool = AGENT_ACCOUNT_TOOLS[agent][0] ?? agent;
2153
+ const [tool, name] = value.includes(":") ? value.split(":", 2) : [candidateTool, value];
2154
+ if (!tool || !name)
2155
+ return null;
2156
+ return {
2157
+ account_key: accountKey(tool, name),
2158
+ account_tool: tool,
2159
+ account_name: name,
2160
+ account_source: "override"
2161
+ };
2162
+ }
2163
+ function envOverride(agent, env) {
2164
+ const agentPrefix = agent.toUpperCase().replace(/[^A-Z0-9]/g, "_");
2165
+ const raw = env[`ECONOMY_${agentPrefix}_ACCOUNT_KEY`] ?? env[`ECONOMY_${agentPrefix}_ACCOUNT`] ?? env["ECONOMY_ACCOUNT_KEY"] ?? env["ECONOMY_ACCOUNT"];
2166
+ if (raw)
2167
+ return fromOverride(raw, agent);
2168
+ const tool = env[`ECONOMY_${agentPrefix}_ACCOUNT_TOOL`] ?? env["ECONOMY_ACCOUNT_TOOL"];
2169
+ const name = env[`ECONOMY_${agentPrefix}_ACCOUNT_NAME`] ?? env["ECONOMY_ACCOUNT_NAME"];
2170
+ if (!tool || !name)
2171
+ return null;
2172
+ return {
2173
+ account_key: accountKey(tool, name),
2174
+ account_tool: tool,
2175
+ account_name: name,
2176
+ account_email: env[`ECONOMY_${agentPrefix}_ACCOUNT_EMAIL`] ?? env["ECONOMY_ACCOUNT_EMAIL"],
2177
+ account_source: "override"
2178
+ };
2179
+ }
2180
+ function knownToolIds(api) {
2181
+ try {
2182
+ return new Set(api.listTools().map((tool) => tool.id));
2183
+ } catch {
2184
+ return new Set;
2185
+ }
2186
+ }
2187
+ function profileForEnvDir(api, tool, env) {
2188
+ const configuredDir = env[tool.envVar];
2189
+ if (!configuredDir)
2190
+ return null;
2191
+ const normalized = normalizeDir(configuredDir);
2192
+ try {
2193
+ return api.listProfiles(tool.id).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
2194
+ } catch {
2195
+ return null;
2196
+ }
2197
+ }
2198
+ async function resolveAccountForAgent(agent, env = process.env) {
2199
+ const override = envOverride(agent, env);
2200
+ if (override)
2201
+ return override;
2202
+ let api;
2203
+ try {
2204
+ api = await import("@hasna/accounts");
2205
+ } catch {
2206
+ return null;
2207
+ }
2208
+ const toolIds = knownToolIds(api);
2209
+ for (const toolId of AGENT_ACCOUNT_TOOLS[agent]) {
2210
+ if (!toolIds.has(toolId))
2211
+ continue;
2212
+ let tool;
2213
+ try {
2214
+ tool = api.getTool(toolId);
2215
+ } catch {
2216
+ continue;
2217
+ }
2218
+ const envProfile = profileForEnvDir(api, tool, env);
2219
+ if (envProfile)
2220
+ return fromProfile(envProfile, "env");
2221
+ try {
2222
+ const applied = api.appliedProfile(toolId);
2223
+ if (applied)
2224
+ return fromProfile(applied, "applied");
2225
+ } catch {}
2226
+ try {
2227
+ const current = api.currentProfile(toolId);
2228
+ if (current)
2229
+ return fromProfile(current, "current");
2230
+ } catch {}
2231
+ }
2232
+ return null;
2233
+ }
2234
+ function withAccount(record, account) {
2235
+ if (!account)
2236
+ return record;
2237
+ return {
2238
+ ...record,
2239
+ account_key: account.account_key,
2240
+ account_tool: account.account_tool,
2241
+ account_name: account.account_name,
2242
+ account_email: account.account_email ?? "",
2243
+ account_source: account.account_source
2244
+ };
2245
+ }
2246
+
2247
+ // src/ingest/claude.ts
2248
+ function autoDetectProject(cwd, projects) {
2249
+ return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
2250
+ }
2251
+ var CLAUDE_PROJECTS_DIR = join2(homedir2(), ".claude", "projects");
2252
+ var TAKUMI_PROJECTS_DIR = join2(homedir2(), ".takumi", "projects");
2253
+ function dirNameToPath(dirName) {
2254
+ return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
2255
+ }
2256
+ function collectJsonlFiles(projectDir) {
2257
+ const files = [];
2258
+ function walk(dir) {
2259
+ try {
2260
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
2261
+ if (entry.isDirectory())
2262
+ walk(join2(dir, entry.name));
2263
+ else if (entry.name.endsWith(".jsonl"))
2264
+ files.push(join2(dir, entry.name));
2265
+ }
2266
+ } catch {}
2267
+ }
2268
+ walk(projectDir);
2269
+ return files;
2270
+ }
2271
+ async function ingestClaude(db, verbose = false, projectsDir = CLAUDE_PROJECTS_DIR) {
2272
+ return ingestJsonlProjects(db, projectsDir, "claude", verbose);
2273
+ }
2274
+ async function ingestTakumi(db, verbose = false, projectsDir = TAKUMI_PROJECTS_DIR) {
2275
+ return ingestJsonlProjects(db, projectsDir, "takumi", verbose);
2276
+ }
2277
+ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false) {
2278
+ if (!existsSync2(projectsDir)) {
2279
+ if (verbose)
2280
+ console.log(`${agentName} projects dir not found:`, projectsDir);
2281
+ return { files: 0, requests: 0, sessions: 0 };
2282
+ }
2283
+ const machineId = getMachineId();
2284
+ let totalFiles = 0;
2285
+ let totalRequests = 0;
2286
+ const touchedSessions = new Set;
2287
+ const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
2288
+ const account = await resolveAccountForAgent(agentName);
2289
+ const projectDirs = readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
2290
+ for (const projectDirEntry of projectDirs) {
2291
+ const projectDirPath = join2(projectsDir, projectDirEntry.name);
2292
+ const projectPath = dirNameToPath(projectDirEntry.name);
2293
+ const jsonlFiles = collectJsonlFiles(projectDirPath);
2294
+ for (const filePath of jsonlFiles) {
2295
+ const stateKey = filePath.replace(projectsDir, "");
2296
+ let fileMtime = "0";
2297
+ try {
2298
+ fileMtime = statSync2(filePath).mtimeMs.toString();
2299
+ } catch {
2300
+ continue;
2301
+ }
2302
+ const processed = getIngestState(db, agentName, stateKey);
2303
+ if (processed === fileMtime)
2304
+ continue;
2305
+ let lines;
2306
+ try {
2307
+ lines = readFileSync(filePath, "utf-8").split(`
596
2308
  `).filter((l) => l.trim());
597
2309
  } catch {
598
2310
  continue;
@@ -622,27 +2334,39 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
622
2334
  continue;
623
2335
  const inputTokens = usage.input_tokens ?? 0;
624
2336
  const outputTokens = usage.output_tokens ?? 0;
625
- const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
2337
+ const cacheWrite5mTokens = usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? 0;
2338
+ const cacheWrite1hTokens = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
2339
+ const cacheWriteTokens = cacheWrite5mTokens + cacheWrite1hTokens;
626
2340
  const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
627
2341
  const timestamp = entry.timestamp ?? new Date().toISOString();
628
- if (inputTokens + outputTokens + cacheWriteTokens === 0)
2342
+ if (inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens === 0)
629
2343
  continue;
630
- const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
631
- const reqId = `claude-${sessionId}-${timestamp}`;
632
- upsertRequest(db, {
2344
+ let costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens);
2345
+ costUsd = applyClaudeModifiers(costUsd, model, usage, entry);
2346
+ const serverToolUse = usage.server_tool_use;
2347
+ if (serverToolUse?.web_search_requests) {
2348
+ costUsd += serverToolUse.web_search_requests * 0.01;
2349
+ }
2350
+ const sourceRequestId = entry.requestId ?? entry.request_id ?? entry.message.id ?? entry.uuid ?? `${sessionId}-${timestamp}`;
2351
+ const reqId = `${agentName}-${sourceRequestId}`;
2352
+ upsertRequest(db, withAccount({
633
2353
  id: reqId,
634
- agent: "claude",
2354
+ agent: agentName,
635
2355
  session_id: sessionId,
636
2356
  model,
637
2357
  input_tokens: inputTokens,
638
2358
  output_tokens: outputTokens,
639
2359
  cache_read_tokens: cacheReadTokens,
640
2360
  cache_create_tokens: cacheWriteTokens,
2361
+ cache_create_5m_tokens: cacheWrite5mTokens,
2362
+ cache_create_1h_tokens: cacheWrite1hTokens,
641
2363
  cost_usd: costUsd,
2364
+ cost_basis: defaultCostBasisForAgent(agentName),
642
2365
  duration_ms: 0,
643
2366
  timestamp,
644
- source_request_id: reqId
645
- });
2367
+ source_request_id: sourceRequestId,
2368
+ machine_id: machineId
2369
+ }, account));
646
2370
  if (!touchedSessions.has(sessionId)) {
647
2371
  const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
648
2372
  if (!existing) {
@@ -650,90 +2374,1413 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
650
2374
  const detectedProject = autoDetectProject(effectiveCwd, registeredProjects);
651
2375
  const session = {
652
2376
  id: sessionId,
653
- agent: "claude",
2377
+ agent: agentName,
654
2378
  project_path: detectedProject ? detectedProject.path : effectiveCwd,
655
2379
  project_name: detectedProject ? detectedProject.name : "",
656
2380
  started_at: timestamp,
657
2381
  ended_at: null,
658
2382
  total_cost_usd: 0,
659
2383
  total_tokens: 0,
660
- request_count: 0
2384
+ request_count: 0,
2385
+ machine_id: machineId
661
2386
  };
662
- upsertSession(db, session);
2387
+ upsertSession(db, withAccount(session, account));
663
2388
  }
664
2389
  touchedSessions.add(sessionId);
665
2390
  }
666
2391
  totalRequests++;
667
2392
  }
668
- setIngestState(db, "claude", stateKey, fileMtime);
2393
+ setIngestState(db, agentName, stateKey, fileMtime);
669
2394
  totalFiles++;
670
2395
  }
671
2396
  }
672
- for (const sessionId of touchedSessions) {
2397
+ for (const sessionId of touchedSessions) {
2398
+ rollupSession(db, sessionId);
2399
+ }
2400
+ return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
2401
+ }
2402
+ function applyClaudeModifiers(costUsd, model, usage, entry) {
2403
+ let multiplier = 1;
2404
+ const speed = usage.speed ?? entry.message?.speed ?? entry.speed;
2405
+ if (speed === "fast" && model.includes("opus-4-6")) {
2406
+ multiplier *= 6;
2407
+ }
2408
+ const inferenceGeo = usage.inference_geo ?? entry.message?.inference_geo ?? entry.inference_geo;
2409
+ if (inferenceGeo && ["us", "us-only", "us_only"].includes(inferenceGeo) && supportsClaudeDataResidencyPricing(model)) {
2410
+ multiplier *= 1.1;
2411
+ }
2412
+ return costUsd * multiplier;
2413
+ }
2414
+ function supportsClaudeDataResidencyPricing(model) {
2415
+ const normalized = normalizeModelName(model);
2416
+ const match = normalized.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?(?:-|$)/);
2417
+ if (!match)
2418
+ return false;
2419
+ const major = Number(match[2]);
2420
+ const minor = match[3] ? Number(match[3]) : 0;
2421
+ return major > 4 || major === 4 && minor >= 6;
2422
+ }
2423
+
2424
+ // src/ingest/codex.ts
2425
+ init_database();
2426
+ init_pricing();
2427
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2428
+ import { homedir as homedir3 } from "os";
2429
+ import { join as join3, basename as basename2 } from "path";
2430
+ import { Database as BunDatabase } from "bun:sqlite";
2431
+ var DEFAULT_CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
2432
+ var DEFAULT_CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
2433
+ var CODEX_INGEST_VERSION = "rollout-aggregate-v3";
2434
+ function codexDbPath() {
2435
+ return process.env["HASNA_ECONOMY_CODEX_DB_PATH"] ?? DEFAULT_CODEX_DB_PATH;
2436
+ }
2437
+ function codexConfigPath() {
2438
+ return process.env["HASNA_ECONOMY_CODEX_CONFIG_PATH"] ?? DEFAULT_CODEX_CONFIG_PATH;
2439
+ }
2440
+ function readCodexModel() {
2441
+ const configPath = codexConfigPath();
2442
+ if (!existsSync3(configPath))
2443
+ return "gpt-5-codex";
2444
+ try {
2445
+ const content = readFileSync2(configPath, "utf-8");
2446
+ const match = content.match(/^model\s*=\s*"([^"]+)"/m);
2447
+ return match?.[1] ?? "gpt-5-codex";
2448
+ } catch {
2449
+ return "gpt-5-codex";
2450
+ }
2451
+ }
2452
+ function buildThreadQuery(codexDb) {
2453
+ const cols = new Set(codexDb.prepare(`PRAGMA table_info(threads)`).all().map((c) => c.name));
2454
+ const modelSelect = cols.has("model") ? "model" : "NULL AS model";
2455
+ const rolloutSelect = cols.has("rollout_path") ? "rollout_path" : "NULL AS rollout_path";
2456
+ const providerSelect = cols.has("model_provider") ? "model_provider" : "NULL AS model_provider";
2457
+ return `
2458
+ SELECT id, ${rolloutSelect}, cwd, created_at, updated_at, tokens_used, title,
2459
+ ${providerSelect}, ${modelSelect}
2460
+ FROM threads WHERE tokens_used > 0
2461
+ `;
2462
+ }
2463
+ function readTokenEvents(rolloutPath) {
2464
+ if (!rolloutPath || !existsSync3(rolloutPath))
2465
+ return [];
2466
+ const fallbackUsages = new Map;
2467
+ let fallbackTimestamp;
2468
+ let aggregate = null;
2469
+ for (const line of readFileSync2(rolloutPath, "utf-8").split(`
2470
+ `)) {
2471
+ if (!line.trim())
2472
+ continue;
2473
+ let entry;
2474
+ try {
2475
+ entry = JSON.parse(line);
2476
+ } catch {
2477
+ continue;
2478
+ }
2479
+ if (!entry || typeof entry !== "object")
2480
+ continue;
2481
+ const payload = entry["payload"];
2482
+ if (!payload || payload["type"] !== "token_count")
2483
+ continue;
2484
+ const info = payload["info"];
2485
+ const timestamp = entry["timestamp"];
2486
+ const entryTimestamp = typeof timestamp === "string" ? timestamp : undefined;
2487
+ const totalUsage = info?.["total_token_usage"];
2488
+ if (totalUsage && tokenTotal(totalUsage) > 0) {
2489
+ aggregate = { usage: totalUsage, timestamp: entryTimestamp };
2490
+ continue;
2491
+ }
2492
+ const usage = info?.["last_token_usage"];
2493
+ if (!usage)
2494
+ continue;
2495
+ if (tokenTotal(usage) <= 0)
2496
+ continue;
2497
+ const key = JSON.stringify(usage);
2498
+ if (!fallbackUsages.has(key))
2499
+ fallbackUsages.set(key, usage);
2500
+ fallbackTimestamp = entryTimestamp ?? fallbackTimestamp;
2501
+ }
2502
+ if (aggregate)
2503
+ return [aggregate];
2504
+ if (fallbackUsages.size === 0)
2505
+ return [];
2506
+ return [{ usage: sumTokenUsages([...fallbackUsages.values()]), timestamp: fallbackTimestamp }];
2507
+ }
2508
+ function tokenTotal(usage) {
2509
+ return usage.total_tokens ?? (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
2510
+ }
2511
+ function sumTokenUsages(usages) {
2512
+ const result = {
2513
+ input_tokens: 0,
2514
+ cached_input_tokens: 0,
2515
+ output_tokens: 0,
2516
+ reasoning_output_tokens: 0,
2517
+ total_tokens: 0
2518
+ };
2519
+ for (const usage of usages) {
2520
+ result.input_tokens = (result.input_tokens ?? 0) + (usage.input_tokens ?? 0);
2521
+ result.cached_input_tokens = (result.cached_input_tokens ?? 0) + (usage.cached_input_tokens ?? 0);
2522
+ result.output_tokens = (result.output_tokens ?? 0) + (usage.output_tokens ?? 0);
2523
+ result.reasoning_output_tokens = (result.reasoning_output_tokens ?? 0) + (usage.reasoning_output_tokens ?? 0);
2524
+ result.total_tokens = (result.total_tokens ?? 0) + tokenTotal(usage);
2525
+ }
2526
+ return result;
2527
+ }
2528
+ function fallbackEvents(totalTokens) {
2529
+ const inputTokens = Math.floor(totalTokens * 0.6);
2530
+ return [{
2531
+ usage: {
2532
+ input_tokens: inputTokens,
2533
+ cached_input_tokens: 0,
2534
+ output_tokens: totalTokens - inputTokens,
2535
+ total_tokens: totalTokens
2536
+ }
2537
+ }];
2538
+ }
2539
+ async function ingestCodex(db, verbose = false) {
2540
+ const dbPath = codexDbPath();
2541
+ if (!existsSync3(dbPath)) {
2542
+ if (verbose)
2543
+ console.log("Codex DB not found:", dbPath);
2544
+ return { sessions: 0, requests: 0 };
2545
+ }
2546
+ const machineId = getMachineId();
2547
+ let codexDb = null;
2548
+ let ingested = 0;
2549
+ let requests = 0;
2550
+ const account = await resolveAccountForAgent("codex");
2551
+ try {
2552
+ codexDb = new BunDatabase(dbPath, { readonly: true });
2553
+ const threads = codexDb.prepare(buildThreadQuery(codexDb)).all();
2554
+ for (const thread of threads) {
2555
+ const model = thread.model ?? readCodexModel();
2556
+ const stateValue = `${CODEX_INGEST_VERSION}:${thread.updated_at}:${thread.tokens_used}:${model}`;
2557
+ const processed = getIngestState(db, "codex", thread.id);
2558
+ if (processed === stateValue)
2559
+ continue;
2560
+ const projectPath = thread.cwd ?? "";
2561
+ const projectName = projectPath ? basename2(projectPath) : "unknown";
2562
+ const sessionId = `codex-${thread.id}`;
2563
+ const startedAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString();
2564
+ const endedAt = thread.updated_at ? new Date(thread.updated_at * 1000).toISOString() : null;
2565
+ upsertSession(db, withAccount({
2566
+ id: sessionId,
2567
+ agent: "codex",
2568
+ project_path: projectPath,
2569
+ project_name: projectName,
2570
+ started_at: startedAt,
2571
+ ended_at: endedAt,
2572
+ total_cost_usd: 0,
2573
+ total_tokens: 0,
2574
+ request_count: 0,
2575
+ machine_id: machineId
2576
+ }, account));
2577
+ const events = readTokenEvents(thread.rollout_path);
2578
+ const tokenEvents = events.length > 0 ? events : fallbackEvents(thread.tokens_used);
2579
+ const ingestedTokens = tokenEvents.reduce((sum, event) => sum + tokenTotal(event.usage), 0);
2580
+ db.prepare(`DELETE FROM requests WHERE session_id = ?`).run(sessionId);
2581
+ tokenEvents.forEach((event, index) => {
2582
+ const usage = event.usage;
2583
+ const inputTotal = usage.input_tokens ?? 0;
2584
+ const cacheReadTokens = usage.cached_input_tokens ?? 0;
2585
+ const inputTokens = Math.max(inputTotal - cacheReadTokens, 0);
2586
+ const outputTokens = usage.output_tokens ?? Math.max((usage.total_tokens ?? 0) - inputTotal, 0);
2587
+ const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, 0);
2588
+ const timestamp = event.timestamp ?? (thread.created_at ? new Date(thread.created_at * 1000 + index).toISOString() : new Date().toISOString());
2589
+ const requestId = `${sessionId}-${index}`;
2590
+ upsertRequest(db, withAccount({
2591
+ id: requestId,
2592
+ agent: "codex",
2593
+ session_id: sessionId,
2594
+ model,
2595
+ input_tokens: inputTokens,
2596
+ output_tokens: outputTokens,
2597
+ cache_read_tokens: cacheReadTokens,
2598
+ cache_create_tokens: 0,
2599
+ cost_usd: costUsd,
2600
+ cost_basis: defaultCostBasisForAgent("codex"),
2601
+ duration_ms: 0,
2602
+ timestamp,
2603
+ source_request_id: requestId,
2604
+ machine_id: machineId
2605
+ }, account));
2606
+ requests++;
2607
+ });
2608
+ rollupSession(db, sessionId);
2609
+ setIngestState(db, "codex", thread.id, stateValue);
2610
+ ingested++;
2611
+ if (verbose)
2612
+ console.log(`Codex session ${thread.id}: ${ingestedTokens} tokens on ${model}`);
2613
+ }
2614
+ } finally {
2615
+ codexDb?.close();
2616
+ }
2617
+ return { sessions: ingested, requests };
2618
+ }
2619
+
2620
+ // src/ingest/gemini.ts
2621
+ init_database();
2622
+ init_pricing();
2623
+ import { readdirSync as readdirSync3, readFileSync as readFileSync3, existsSync as existsSync4, statSync as statSync3 } from "fs";
2624
+ import { homedir as homedir4 } from "os";
2625
+ import { join as join4, basename as basename3 } from "path";
2626
+ var DEFAULT_GEMINI_TMP_DIR = join4(homedir4(), ".gemini", "tmp");
2627
+ var DEFAULT_GEMINI_HISTORY_DIR = join4(homedir4(), ".gemini", "history");
2628
+ function geminiTmpDir() {
2629
+ return process.env["HASNA_ECONOMY_GEMINI_TMP_DIR"] ?? DEFAULT_GEMINI_TMP_DIR;
2630
+ }
2631
+ function geminiHistoryDir() {
2632
+ return process.env["HASNA_ECONOMY_GEMINI_HISTORY_DIR"] ?? DEFAULT_GEMINI_HISTORY_DIR;
2633
+ }
2634
+ function numberField(...values) {
2635
+ for (const value of values) {
2636
+ if (typeof value === "number" && Number.isFinite(value))
2637
+ return value;
2638
+ }
2639
+ return 0;
2640
+ }
2641
+ function listProjectDirs(...roots) {
2642
+ const dirs = new Set;
2643
+ for (const root of roots) {
2644
+ if (!existsSync4(root))
2645
+ continue;
2646
+ try {
2647
+ for (const entry of readdirSync3(root, { withFileTypes: true })) {
2648
+ if (entry.isDirectory())
2649
+ dirs.add(join4(root, entry.name));
2650
+ }
2651
+ } catch {}
2652
+ }
2653
+ return [...dirs];
2654
+ }
2655
+ function projectRoot(projectDir, chatData) {
2656
+ if (chatData.projectPath)
2657
+ return chatData.projectPath;
2658
+ if (chatData.project_path)
2659
+ return chatData.project_path;
2660
+ const rootFile = join4(projectDir, ".project_root");
2661
+ try {
2662
+ if (existsSync4(rootFile))
2663
+ return readFileSync3(rootFile, "utf-8").trim();
2664
+ } catch {}
2665
+ return "";
2666
+ }
2667
+ async function ingestGemini(db, verbose) {
2668
+ const tmpDir = geminiTmpDir();
2669
+ const historyDir = geminiHistoryDir();
2670
+ if (!existsSync4(tmpDir) && !existsSync4(historyDir)) {
2671
+ if (verbose)
2672
+ console.log("Gemini tmp/history dirs not found:", tmpDir, historyDir);
2673
+ return { sessions: 0, requests: 0 };
2674
+ }
2675
+ const machineId = getMachineId();
2676
+ let totalSessions = 0;
2677
+ let totalRequests = 0;
2678
+ const touchedSessions = new Set;
2679
+ const account = await resolveAccountForAgent("gemini");
2680
+ const projectDirs = listProjectDirs(tmpDir, historyDir);
2681
+ for (const projectDir of projectDirs) {
2682
+ const chatsDir = join4(projectDir, "chats");
2683
+ if (!existsSync4(chatsDir))
2684
+ continue;
2685
+ let chatFiles = [];
2686
+ try {
2687
+ chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join4(chatsDir, f));
2688
+ } catch {
2689
+ continue;
2690
+ }
2691
+ for (const filePath of chatFiles) {
2692
+ const stateKey = filePath.replace(homedir4(), "~");
2693
+ let fileMtime = "0";
2694
+ try {
2695
+ fileMtime = statSync3(filePath).mtimeMs.toString();
2696
+ } catch {
2697
+ continue;
2698
+ }
2699
+ const processed = getIngestState(db, "gemini", stateKey);
2700
+ if (processed === fileMtime)
2701
+ continue;
2702
+ let chatData;
2703
+ try {
2704
+ chatData = JSON.parse(readFileSync3(filePath, "utf-8"));
2705
+ } catch {
2706
+ continue;
2707
+ }
2708
+ const sessionId = chatData.sessionId ?? chatData.id ?? basename3(filePath, ".json");
2709
+ if (!sessionId)
2710
+ continue;
2711
+ const startTime = chatData.startTime ?? new Date().toISOString();
2712
+ const projectPath = projectRoot(projectDir, chatData);
2713
+ const projectName = projectPath ? basename3(projectPath) : "";
2714
+ const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
2715
+ if (!existing) {
2716
+ const session = {
2717
+ id: sessionId,
2718
+ agent: "gemini",
2719
+ project_path: projectPath,
2720
+ project_name: projectName,
2721
+ started_at: startTime,
2722
+ ended_at: chatData.lastUpdated ?? null,
2723
+ total_cost_usd: 0,
2724
+ total_tokens: 0,
2725
+ request_count: 0,
2726
+ machine_id: machineId
2727
+ };
2728
+ upsertSession(db, withAccount(session, account));
2729
+ totalSessions++;
2730
+ }
2731
+ touchedSessions.add(sessionId);
2732
+ for (const [index, message] of (chatData.messages ?? []).entries()) {
2733
+ const usage = message.usage ?? message.usageMetadata ?? message.response?.usageMetadata;
2734
+ if (!usage)
2735
+ continue;
2736
+ const model = message.model ?? message.response?.modelVersion ?? message.response?.model ?? chatData.model;
2737
+ if (!model)
2738
+ continue;
2739
+ const toolUsePromptTokens = numberField(usage.toolUsePromptTokenCount, usage.tool_use_prompt_token_count);
2740
+ const inputTotal = numberField(usage.inputTokens, usage.input_tokens, usage.promptTokenCount, usage.prompt_token_count) + toolUsePromptTokens;
2741
+ const cacheReadTokens = numberField(usage.cachedInputTokens, usage.cache_read_tokens, usage.cachedContentTokenCount, usage.cached_content_token_count);
2742
+ const inputTokens = Math.max(inputTotal - cacheReadTokens, 0);
2743
+ const thoughtsTokens = numberField(usage.thoughtsTokenCount, usage.thoughts_token_count);
2744
+ const outputTokens = numberField(usage.outputTokens, usage.output_tokens, usage.candidatesTokenCount, usage.candidates_token_count) + thoughtsTokens;
2745
+ const totalTokens = numberField(usage.totalTokens, usage.total_tokens, usage.totalTokenCount, usage.total_token_count);
2746
+ if (inputTokens + outputTokens + cacheReadTokens + totalTokens === 0)
2747
+ continue;
2748
+ const computedCost = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, 0);
2749
+ const costUsd = numberField(message.costUsd, message.cost_usd) || computedCost;
2750
+ const timestamp = message.timestamp ?? chatData.lastUpdated ?? startTime;
2751
+ const requestId = `gemini-${sessionId}-${message.id ?? index}`;
2752
+ upsertRequest(db, withAccount({
2753
+ id: requestId,
2754
+ agent: "gemini",
2755
+ session_id: sessionId,
2756
+ model,
2757
+ input_tokens: inputTokens,
2758
+ output_tokens: outputTokens,
2759
+ cache_read_tokens: cacheReadTokens,
2760
+ cache_create_tokens: 0,
2761
+ cost_usd: costUsd,
2762
+ cost_basis: defaultCostBasisForAgent("gemini"),
2763
+ duration_ms: 0,
2764
+ timestamp,
2765
+ source_request_id: message.id ?? requestId,
2766
+ machine_id: machineId
2767
+ }, account));
2768
+ totalRequests++;
2769
+ }
2770
+ setIngestState(db, "gemini", stateKey, fileMtime);
2771
+ }
2772
+ }
2773
+ for (const sessionId of touchedSessions) {
2774
+ rollupSession(db, sessionId);
2775
+ }
2776
+ return { sessions: totalSessions, requests: totalRequests };
2777
+ }
2778
+
2779
+ // src/ingest/opencode.ts
2780
+ init_database();
2781
+ init_pricing();
2782
+ import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync4, statSync as statSync4 } from "fs";
2783
+ import { homedir as homedir5 } from "os";
2784
+ import { join as join5 } from "path";
2785
+ var OPENCODE_STORAGE = join5(homedir5(), ".local", "share", "opencode", "storage");
2786
+ function walkJsonFiles(dir, acc = []) {
2787
+ if (!existsSync5(dir))
2788
+ return acc;
2789
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
2790
+ const p = join5(dir, entry.name);
2791
+ if (entry.isDirectory())
2792
+ walkJsonFiles(p, acc);
2793
+ else if (entry.name.endsWith(".json"))
2794
+ acc.push(p);
2795
+ }
2796
+ return acc;
2797
+ }
2798
+ function parseSessionIdFromPath(filePath) {
2799
+ const parts = filePath.split("/");
2800
+ const msgIdx = parts.indexOf("message");
2801
+ if (msgIdx >= 0 && parts[msgIdx + 1])
2802
+ return parts[msgIdx + 1];
2803
+ const sessionIdx = parts.indexOf("session");
2804
+ if (sessionIdx >= 0 && parts[parts.length - 1]?.endsWith(".json")) {
2805
+ return parts[parts.length - 1].replace(/\.json$/, "");
2806
+ }
2807
+ return null;
2808
+ }
2809
+ async function ingestOpenCode(db, verbose = false) {
2810
+ const messageDir = join5(OPENCODE_STORAGE, "message");
2811
+ const files = walkJsonFiles(messageDir);
2812
+ let requests = 0;
2813
+ const touched = new Set;
2814
+ const machineId = getMachineId();
2815
+ const now = new Date().toISOString();
2816
+ const account = await resolveAccountForAgent("opencode");
2817
+ for (const file of files) {
2818
+ const mtime = statSync4(file).mtimeMs;
2819
+ const stateKey = file;
2820
+ const prev = getIngestState(db, "opencode", stateKey);
2821
+ if (prev && Number(prev) >= mtime)
2822
+ continue;
2823
+ let parsed;
2824
+ try {
2825
+ parsed = JSON.parse(readFileSync4(file, "utf-8"));
2826
+ } catch {
2827
+ continue;
2828
+ }
2829
+ if (parsed.role !== "assistant")
2830
+ continue;
2831
+ const usage = parsed.usage;
2832
+ if (!usage)
2833
+ continue;
2834
+ const sessionId = parseSessionIdFromPath(file) ?? `opencode-${statSync4(file).ino}`;
2835
+ const model = normalizeModelName(parsed.model ?? "unknown");
2836
+ const input = usage.inputTokens ?? 0;
2837
+ const output = usage.outputTokens ?? 0;
2838
+ const cacheRead = usage.cacheReadTokens ?? 0;
2839
+ const cacheWrite = usage.cacheWriteTokens ?? 0;
2840
+ if (input + output + cacheRead + cacheWrite === 0 && !usage.cost)
2841
+ continue;
2842
+ const timestamp = usage && parsed.time?.created ? new Date(parsed.time.created).toISOString() : new Date(statSync4(file).mtime).toISOString();
2843
+ const sourceId = file.replace(OPENCODE_STORAGE, "");
2844
+ const reqId = `opencode-${sourceId}`;
2845
+ const costUsd = usage.cost ?? computeCostFromDb(db, model, input, output, cacheRead, cacheWrite, 0);
2846
+ upsertRequest(db, withAccount({
2847
+ id: reqId,
2848
+ agent: "opencode",
2849
+ session_id: sessionId,
2850
+ model,
2851
+ input_tokens: input,
2852
+ output_tokens: output,
2853
+ cache_read_tokens: cacheRead,
2854
+ cache_create_tokens: cacheWrite,
2855
+ cost_usd: costUsd,
2856
+ cost_basis: defaultCostBasisForAgent("opencode"),
2857
+ duration_ms: 0,
2858
+ timestamp,
2859
+ source_request_id: sourceId,
2860
+ machine_id: machineId,
2861
+ updated_at: now
2862
+ }, account));
2863
+ requests++;
2864
+ if (!touched.has(sessionId)) {
2865
+ upsertSession(db, withAccount({
2866
+ id: sessionId,
2867
+ agent: "opencode",
2868
+ project_path: "",
2869
+ project_name: "",
2870
+ started_at: timestamp,
2871
+ ended_at: null,
2872
+ total_cost_usd: 0,
2873
+ total_tokens: 0,
2874
+ request_count: 0,
2875
+ machine_id: machineId,
2876
+ updated_at: now
2877
+ }, account));
2878
+ touched.add(sessionId);
2879
+ }
2880
+ setIngestState(db, "opencode", stateKey, String(mtime));
2881
+ if (verbose)
2882
+ console.log(` opencode: ${reqId} ${model} $${costUsd.toFixed(4)}`);
2883
+ }
2884
+ for (const sid of touched)
2885
+ rollupSession(db, sid);
2886
+ return { files: files.length, requests, sessions: touched.size };
2887
+ }
2888
+
2889
+ // src/ingest/cursor.ts
2890
+ init_database();
2891
+ function getCursorSessionToken() {
2892
+ return process.env["CURSOR_SESSION_TOKEN"] ?? process.env["CURSOR_API_TOKEN"] ?? null;
2893
+ }
2894
+ async function cursorFetch(path, token) {
2895
+ try {
2896
+ const res = await fetch(`https://cursor.com${path}`, {
2897
+ headers: {
2898
+ Cookie: `WorkosCursorSessionToken=${token}`,
2899
+ Accept: "application/json"
2900
+ },
2901
+ signal: AbortSignal.timeout(1e4)
2902
+ });
2903
+ if (!res.ok)
2904
+ return null;
2905
+ return await res.json();
2906
+ } catch {
2907
+ return null;
2908
+ }
2909
+ }
2910
+ async function ingestCursor(db, verbose = false) {
2911
+ const token = getCursorSessionToken();
2912
+ if (!token) {
2913
+ if (verbose)
2914
+ console.log(" cursor: skipped \u2014 set CURSOR_SESSION_TOKEN");
2915
+ return { requests: 0, snapshots: 0 };
2916
+ }
2917
+ const today = new Date().toISOString().substring(0, 10);
2918
+ const prev = getIngestState(db, "cursor", `sync-${today}`);
2919
+ if (prev)
2920
+ return { requests: 0, snapshots: 0 };
2921
+ const machineId = getMachineId();
2922
+ const now = new Date().toISOString();
2923
+ let snapshots = 0;
2924
+ const account = await resolveAccountForAgent("cursor");
2925
+ const usage = await cursorFetch("/api/usage", token);
2926
+ if (usage?.premiumRequests != null && usage.maxPremiumRequests) {
2927
+ upsertUsageSnapshot(db, {
2928
+ agent: "cursor",
2929
+ date: today,
2930
+ metric: "premium_requests_used",
2931
+ value: usage.premiumRequests,
2932
+ unit: "count",
2933
+ machine_id: machineId
2934
+ });
2935
+ upsertUsageSnapshot(db, {
2936
+ agent: "cursor",
2937
+ date: today,
2938
+ metric: "premium_requests_limit",
2939
+ value: usage.maxPremiumRequests,
2940
+ unit: "count",
2941
+ machine_id: machineId
2942
+ });
2943
+ snapshots += 2;
2944
+ }
2945
+ const summary = await cursorFetch("/api/usage-summary", token);
2946
+ const onDemand = summary?.individualUsage?.spend ?? summary?.teamUsage?.spend ?? 0;
2947
+ const included = summary?.individualUsage?.includedSpend ?? 0;
2948
+ if (onDemand > 0) {
2949
+ upsertUsageSnapshot(db, {
2950
+ agent: "cursor",
2951
+ date: today,
2952
+ metric: "on_demand_usd",
2953
+ value: onDemand,
2954
+ unit: "usd",
2955
+ machine_id: machineId
2956
+ });
2957
+ snapshots++;
2958
+ }
2959
+ if (included > 0) {
2960
+ upsertUsageSnapshot(db, {
2961
+ agent: "cursor",
2962
+ date: today,
2963
+ metric: "included_consumed_usd",
2964
+ value: included,
2965
+ unit: "usd",
2966
+ machine_id: machineId
2967
+ });
2968
+ snapshots++;
2969
+ }
2970
+ const sessionId = `cursor-${today}-${machineId}`;
2971
+ if (onDemand + included > 0) {
2972
+ upsertSession(db, withAccount({
2973
+ id: sessionId,
2974
+ agent: "cursor",
2975
+ project_path: "",
2976
+ project_name: "Cursor subscription",
2977
+ started_at: `${today}T00:00:00.000Z`,
2978
+ ended_at: now,
2979
+ total_cost_usd: onDemand + included,
2980
+ total_tokens: 0,
2981
+ request_count: 1,
2982
+ machine_id: machineId,
2983
+ updated_at: now
2984
+ }, account));
2985
+ upsertRequest(db, withAccount({
2986
+ id: `cursor-${today}-${machineId}-usage`,
2987
+ agent: "cursor",
2988
+ session_id: sessionId,
2989
+ model: "cursor-subscription",
2990
+ input_tokens: 0,
2991
+ output_tokens: 0,
2992
+ cache_read_tokens: 0,
2993
+ cache_create_tokens: 0,
2994
+ cost_usd: onDemand + included,
2995
+ cost_basis: "subscription_included",
2996
+ duration_ms: 0,
2997
+ timestamp: now,
2998
+ source_request_id: today,
2999
+ machine_id: machineId,
3000
+ updated_at: now
3001
+ }, account));
3002
+ rollupSession(db, sessionId);
3003
+ }
3004
+ setIngestState(db, "cursor", `sync-${today}`, now);
3005
+ if (verbose)
3006
+ console.log(` cursor: on-demand $${onDemand.toFixed(2)}, included $${included.toFixed(2)}`);
3007
+ return { requests: onDemand + included > 0 ? 1 : 0, snapshots };
3008
+ }
3009
+
3010
+ // src/ingest/pi.ts
3011
+ init_database();
3012
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
3013
+ import { homedir as homedir6 } from "os";
3014
+ import { join as join6 } from "path";
3015
+ var PI_SESSION_DIR = process.env["PI_CODING_AGENT_SESSION_DIR"] ?? join6(homedir6(), ".pi", "agent", "sessions");
3016
+ function walkSessions(dir, acc = []) {
3017
+ if (!existsSync6(dir))
3018
+ return acc;
3019
+ for (const entry of readdirSync5(dir, { withFileTypes: true })) {
3020
+ const p = join6(dir, entry.name);
3021
+ if (entry.isDirectory())
3022
+ walkSessions(p, acc);
3023
+ else if (entry.name.endsWith(".json"))
3024
+ acc.push(p);
3025
+ }
3026
+ return acc;
3027
+ }
3028
+ async function ingestPi(db, verbose = false) {
3029
+ const files = walkSessions(PI_SESSION_DIR);
3030
+ let requests = 0;
3031
+ const touched = new Set;
3032
+ const machineId = getMachineId();
3033
+ const now = new Date().toISOString();
3034
+ const account = await resolveAccountForAgent("pi");
3035
+ for (const file of files) {
3036
+ const mtime = statSync5(file).mtimeMs;
3037
+ const prev = getIngestState(db, "pi", file);
3038
+ if (prev && Number(prev) >= mtime)
3039
+ continue;
3040
+ let data;
3041
+ try {
3042
+ data = JSON.parse(readFileSync5(file, "utf-8"));
3043
+ } catch {
3044
+ continue;
3045
+ }
3046
+ const sessionId = data.id ?? file.replace(/\.json$/, "").split("/").pop() ?? `pi-${statSync5(file).ino}`;
3047
+ const turns = data.turns ?? data.messages?.filter((m) => m.role === "assistant") ?? [];
3048
+ for (let i = 0;i < turns.length; i++) {
3049
+ const turn = turns[i];
3050
+ const usage = turn.usage;
3051
+ if (!usage)
3052
+ continue;
3053
+ const input = usage.input ?? 0;
3054
+ const output = usage.output ?? 0;
3055
+ if (input + output === 0 && !usage.cost)
3056
+ continue;
3057
+ const model = turn.model ?? turn.provider ?? "unknown";
3058
+ const timestamp = turn.timestamp ?? new Date(statSync5(file).mtime).toISOString();
3059
+ const reqId = `pi-${sessionId}-${i}`;
3060
+ upsertRequest(db, withAccount({
3061
+ id: reqId,
3062
+ agent: "pi",
3063
+ session_id: sessionId,
3064
+ model,
3065
+ input_tokens: input,
3066
+ output_tokens: output,
3067
+ cache_read_tokens: usage.cacheRead ?? 0,
3068
+ cache_create_tokens: usage.cacheWrite ?? 0,
3069
+ cost_usd: usage.cost ?? 0,
3070
+ cost_basis: defaultCostBasisForAgent("pi"),
3071
+ duration_ms: 0,
3072
+ timestamp,
3073
+ source_request_id: `${sessionId}-${i}`,
3074
+ machine_id: machineId,
3075
+ updated_at: now
3076
+ }, account));
3077
+ requests++;
3078
+ }
3079
+ if (turns.length > 0) {
3080
+ upsertSession(db, withAccount({
3081
+ id: sessionId,
3082
+ agent: "pi",
3083
+ project_path: "",
3084
+ project_name: "",
3085
+ started_at: turns[0]?.timestamp ?? now,
3086
+ ended_at: null,
3087
+ total_cost_usd: 0,
3088
+ total_tokens: 0,
3089
+ request_count: 0,
3090
+ machine_id: machineId,
3091
+ updated_at: now
3092
+ }, account));
3093
+ touched.add(sessionId);
3094
+ }
3095
+ setIngestState(db, "pi", file, String(mtime));
3096
+ if (verbose)
3097
+ console.log(` pi: ${sessionId} (${turns.length} turns)`);
3098
+ }
3099
+ for (const sid of touched)
3100
+ rollupSession(db, sid);
3101
+ return { files: files.length, requests, sessions: touched.size };
3102
+ }
3103
+
3104
+ // src/ingest/hermes.ts
3105
+ init_database();
3106
+ import { existsSync as existsSync7, statSync as statSync6 } from "fs";
3107
+ import { homedir as homedir7 } from "os";
3108
+ import { join as join7 } from "path";
3109
+ var HERMES_DB = join7(homedir7(), ".hermes", "state.db");
3110
+ function mapCostBasis(billingMode) {
3111
+ if (billingMode === "subscription")
3112
+ return "subscription_included";
3113
+ if (billingMode === "api")
3114
+ return "metered_api";
3115
+ return defaultCostBasisForAgent("hermes");
3116
+ }
3117
+ async function ingestHermes(db, verbose = false) {
3118
+ if (!existsSync7(HERMES_DB)) {
3119
+ return { sessions: 0, requests: 0 };
3120
+ }
3121
+ const { Database: Sqlite } = await import("bun:sqlite");
3122
+ const hermes = new Sqlite(HERMES_DB, { readonly: true });
3123
+ const rows = hermes.prepare(`
3124
+ SELECT id, source, model, started_at, ended_at,
3125
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
3126
+ reasoning_tokens, estimated_cost_usd, actual_cost_usd, billing_mode, parent_session_id
3127
+ FROM sessions
3128
+ ORDER BY started_at DESC
3129
+ `).all();
3130
+ const stateKey = "state.db";
3131
+ const mtime = statSyncSafe(HERMES_DB);
3132
+ const prev = getIngestState(db, "hermes", stateKey);
3133
+ if (prev && Number(prev) >= mtime && rows.length === 0) {
3134
+ hermes.close();
3135
+ return { sessions: 0, requests: 0 };
3136
+ }
3137
+ const machineId = getMachineId();
3138
+ const now = new Date().toISOString();
3139
+ let requests = 0;
3140
+ const account = await resolveAccountForAgent("hermes");
3141
+ for (const row of rows) {
3142
+ const sessionId = `hermes-${row.id}`;
3143
+ const startedAt = new Date(row.started_at * 1000).toISOString();
3144
+ const endedAt = row.ended_at ? new Date(row.ended_at * 1000).toISOString() : null;
3145
+ const cost = row.actual_cost_usd ?? row.estimated_cost_usd ?? 0;
3146
+ const tokens = row.input_tokens + row.output_tokens + row.cache_read_tokens + row.cache_write_tokens + row.reasoning_tokens;
3147
+ upsertSession(db, withAccount({
3148
+ id: sessionId,
3149
+ agent: "hermes",
3150
+ project_path: row.source ?? "",
3151
+ project_name: row.source ?? "",
3152
+ started_at: startedAt,
3153
+ ended_at: endedAt,
3154
+ total_cost_usd: cost,
3155
+ total_tokens: tokens,
3156
+ request_count: 1,
3157
+ machine_id: machineId,
3158
+ updated_at: now
3159
+ }, account));
3160
+ const reqId = `hermes-${row.id}-rollup`;
3161
+ upsertRequest(db, withAccount({
3162
+ id: reqId,
3163
+ agent: "hermes",
3164
+ session_id: sessionId,
3165
+ model: row.model ?? "unknown",
3166
+ input_tokens: row.input_tokens,
3167
+ output_tokens: row.output_tokens + row.reasoning_tokens,
3168
+ cache_read_tokens: row.cache_read_tokens,
3169
+ cache_create_tokens: row.cache_write_tokens,
3170
+ cost_usd: cost,
3171
+ cost_basis: mapCostBasis(row.billing_mode),
3172
+ duration_ms: 0,
3173
+ timestamp: endedAt ?? startedAt,
3174
+ source_request_id: row.id,
3175
+ machine_id: machineId,
3176
+ updated_at: now
3177
+ }, account));
3178
+ requests++;
673
3179
  rollupSession(db, sessionId);
3180
+ if (verbose)
3181
+ console.log(` hermes: ${sessionId} $${cost.toFixed(4)}`);
3182
+ }
3183
+ setIngestState(db, "hermes", stateKey, String(mtime));
3184
+ hermes.close();
3185
+ return { sessions: rows.length, requests };
3186
+ }
3187
+ function statSyncSafe(path) {
3188
+ try {
3189
+ return statSync6(path).mtimeMs;
3190
+ } catch {
3191
+ return 0;
674
3192
  }
675
- return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
676
3193
  }
677
3194
 
678
- // src/ingest/codex.ts
3195
+ // src/ingest/claude-quota.ts
679
3196
  init_database();
680
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
681
- import { homedir as homedir3 } from "os";
682
- import { join as join3, basename as basename2 } from "path";
683
- import { Database as Database2 } from "bun:sqlite";
684
- var CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
685
- var CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
686
- async function ingestCodex(db, verbose = false) {
687
- if (!existsSync3(CODEX_DB_PATH)) {
3197
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
3198
+
3199
+ // src/lib/paths.ts
3200
+ import { homedir as homedir8 } from "os";
3201
+ import { join as join8 } from "path";
3202
+ function getHomeDir() {
3203
+ return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir8();
3204
+ }
3205
+ function agentPaths() {
3206
+ const home = getHomeDir();
3207
+ return {
3208
+ claudeProjects: join8(home, ".claude", "projects"),
3209
+ claudeCredentials: join8(home, ".claude", ".credentials.json"),
3210
+ takumiProjects: join8(home, ".takumi", "projects"),
3211
+ codexDir: join8(home, ".codex"),
3212
+ codexDb: join8(home, ".codex", "state_5.sqlite"),
3213
+ codexAuth: join8(home, ".codex", "auth.json"),
3214
+ codexConfig: join8(home, ".codex", "config.toml"),
3215
+ geminiTmp: join8(home, ".gemini", "tmp"),
3216
+ geminiHistory: join8(home, ".gemini", "history"),
3217
+ opencodeMessages: join8(home, ".local", "share", "opencode", "storage", "message"),
3218
+ piSessions: join8(home, ".pi", "agent", "sessions"),
3219
+ hermesDir: join8(home, ".hermes"),
3220
+ hermesDb: join8(home, ".hermes", "state.db")
3221
+ };
3222
+ }
3223
+
3224
+ // src/ingest/claude-quota.ts
3225
+ var CREDENTIALS_PATH = agentPaths().claudeCredentials;
3226
+ var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
3227
+ var OAUTH_BETA = "oauth-2025-04-20";
3228
+ function readClaudeToken() {
3229
+ const fromEnv = process.env["CLAUDE_OAUTH_TOKEN"] ?? process.env["ANTHROPIC_OAUTH_TOKEN"];
3230
+ if (fromEnv)
3231
+ return { token: fromEnv };
3232
+ if (!existsSync8(CREDENTIALS_PATH))
3233
+ return null;
3234
+ try {
3235
+ const creds = JSON.parse(readFileSync6(CREDENTIALS_PATH, "utf-8"));
3236
+ const oauth = creds.claudeAiOauth;
3237
+ if (!oauth?.accessToken)
3238
+ return null;
3239
+ return {
3240
+ token: oauth.accessToken,
3241
+ subscriptionType: oauth.subscriptionType,
3242
+ rateLimitTier: oauth.rateLimitTier
3243
+ };
3244
+ } catch {
3245
+ return null;
3246
+ }
3247
+ }
3248
+ function inferMonthlyFee(subscriptionType, rateLimitTier) {
3249
+ const tier = `${subscriptionType ?? ""} ${rateLimitTier ?? ""}`.toLowerCase();
3250
+ if (tier.includes("max") && tier.includes("20"))
3251
+ return 200;
3252
+ if (tier.includes("max"))
3253
+ return 100;
3254
+ if (tier.includes("pro"))
3255
+ return 20;
3256
+ if (tier.includes("team"))
3257
+ return 30;
3258
+ return 20;
3259
+ }
3260
+ async function fetchClaudeOAuthUsage(token) {
3261
+ try {
3262
+ const res = await fetch(USAGE_URL, {
3263
+ headers: {
3264
+ Authorization: `Bearer ${token}`,
3265
+ "anthropic-beta": OAUTH_BETA,
3266
+ Accept: "application/json"
3267
+ },
3268
+ signal: AbortSignal.timeout(1e4)
3269
+ });
3270
+ if (!res.ok)
3271
+ return null;
3272
+ return await res.json();
3273
+ } catch {
3274
+ return null;
3275
+ }
3276
+ }
3277
+ async function ingestClaudeQuota(db, verbose = false) {
3278
+ const auth = readClaudeToken();
3279
+ if (!auth) {
688
3280
  if (verbose)
689
- console.log("Codex DB not found:", CODEX_DB_PATH);
690
- return { sessions: 0 };
3281
+ console.log(" claude quota: skipped \u2014 no OAuth token (~/.claude/.credentials.json or CLAUDE_OAUTH_TOKEN)");
3282
+ return { snapshots: 0 };
691
3283
  }
692
- let codexDb = null;
693
- let ingested = 0;
3284
+ const today = new Date().toISOString().substring(0, 10);
3285
+ const prev = getIngestState(db, "claude", `quota-${today}`);
3286
+ if (prev)
3287
+ return { snapshots: 0 };
3288
+ const usage = await fetchClaudeOAuthUsage(auth.token);
3289
+ if (!usage) {
3290
+ if (verbose)
3291
+ console.log(" claude quota: OAuth usage endpoint unavailable");
3292
+ return { snapshots: 0 };
3293
+ }
3294
+ const machineId = getMachineId();
3295
+ let snapshots = 0;
3296
+ const windows = [
3297
+ ["five_hour_utilization", usage.five_hour],
3298
+ ["seven_day_utilization", usage.seven_day],
3299
+ ["seven_day_sonnet_utilization", usage.seven_day_sonnet],
3300
+ ["seven_day_opus_utilization", usage.seven_day_opus]
3301
+ ];
3302
+ for (const [metric, window] of windows) {
3303
+ if (window?.utilization == null)
3304
+ continue;
3305
+ upsertUsageSnapshot(db, {
3306
+ agent: "claude",
3307
+ date: today,
3308
+ metric,
3309
+ value: Math.round(window.utilization * 1000) / 10,
3310
+ unit: "percent",
3311
+ machine_id: machineId
3312
+ });
3313
+ snapshots++;
3314
+ if (window.resets_at) {
3315
+ upsertUsageSnapshot(db, {
3316
+ agent: "claude",
3317
+ date: today,
3318
+ metric: `${metric}_resets_at`,
3319
+ value: Date.parse(window.resets_at),
3320
+ unit: "epoch_ms",
3321
+ machine_id: machineId
3322
+ });
3323
+ snapshots++;
3324
+ }
3325
+ }
3326
+ if (usage.extra_usage?.spend != null) {
3327
+ upsertUsageSnapshot(db, {
3328
+ agent: "claude",
3329
+ date: today,
3330
+ metric: "on_demand_usd",
3331
+ value: usage.extra_usage.spend,
3332
+ unit: "usd",
3333
+ machine_id: machineId
3334
+ });
3335
+ snapshots++;
3336
+ }
3337
+ if (usage.extra_usage?.limit != null) {
3338
+ upsertUsageSnapshot(db, {
3339
+ agent: "claude",
3340
+ date: today,
3341
+ metric: "on_demand_limit_usd",
3342
+ value: usage.extra_usage.limit,
3343
+ unit: "usd",
3344
+ machine_id: machineId
3345
+ });
3346
+ snapshots++;
3347
+ }
3348
+ const monthlyFee = inferMonthlyFee(auth.subscriptionType, auth.rateLimitTier);
3349
+ const now = new Date().toISOString();
3350
+ upsertSubscription(db, {
3351
+ id: "anthropic-claude-oauth",
3352
+ provider: "anthropic",
3353
+ agent: "claude",
3354
+ plan: auth.rateLimitTier ?? auth.subscriptionType ?? "claude_pro",
3355
+ monthly_fee_usd: monthlyFee,
3356
+ included_usage_usd: monthlyFee,
3357
+ billing_cycle_start: null,
3358
+ reset_policy: "monthly",
3359
+ active: 1,
3360
+ created_at: now,
3361
+ updated_at: now
3362
+ });
3363
+ setIngestState(db, "claude", `quota-${today}`, new Date().toISOString());
3364
+ if (verbose)
3365
+ console.log(` claude quota: ${snapshots} snapshots`);
3366
+ return { snapshots };
3367
+ }
3368
+
3369
+ // src/ingest/codex-quota.ts
3370
+ init_database();
3371
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
3372
+ var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
3373
+ function readCodexAuth() {
3374
+ const fromEnv = process.env["CODEX_OAUTH_TOKEN"];
3375
+ if (fromEnv)
3376
+ return { token: fromEnv, authMode: "chatgpt" };
3377
+ const authPath = agentPaths().codexAuth;
3378
+ if (!existsSync9(authPath))
3379
+ return null;
694
3380
  try {
695
- codexDb = new Database2(CODEX_DB_PATH, { readonly: true });
696
- const threads = codexDb.prepare(`SELECT id, cwd, created_at, updated_at, tokens_used, title FROM threads WHERE tokens_used > 0`).all();
697
- for (const thread of threads) {
698
- const stateKey = thread.id;
699
- const processed = getIngestState(db, "codex", stateKey);
700
- if (processed === "done")
701
- continue;
702
- const costUsd = 0;
703
- const projectPath = thread.cwd ?? "";
704
- const projectName = projectPath ? basename2(projectPath) : "unknown";
705
- const startedAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString();
706
- const endedAt = thread.updated_at ? new Date(thread.updated_at * 1000).toISOString() : null;
707
- upsertSession(db, {
708
- id: `codex-${thread.id}`,
3381
+ const auth = JSON.parse(readFileSync7(authPath, "utf-8"));
3382
+ const token = auth.tokens?.access_token;
3383
+ if (!token)
3384
+ return null;
3385
+ return {
3386
+ token,
3387
+ accountId: auth.tokens?.account_id,
3388
+ authMode: auth.auth_mode ?? "chatgpt"
3389
+ };
3390
+ } catch {
3391
+ return null;
3392
+ }
3393
+ }
3394
+ function planMonthlyFee(planType) {
3395
+ const plan = (planType ?? "").toLowerCase();
3396
+ if (plan.includes("pro"))
3397
+ return 200;
3398
+ if (plan.includes("plus"))
3399
+ return 20;
3400
+ if (plan.includes("team"))
3401
+ return 30;
3402
+ return 20;
3403
+ }
3404
+ async function fetchCodexUsage(token, accountId) {
3405
+ try {
3406
+ const headers = {
3407
+ Authorization: `Bearer ${token}`,
3408
+ Accept: "application/json",
3409
+ "User-Agent": "economy-cli"
3410
+ };
3411
+ if (accountId)
3412
+ headers["ChatGPT-Account-Id"] = accountId;
3413
+ const res = await fetch(WHAM_USAGE_URL, {
3414
+ headers,
3415
+ signal: AbortSignal.timeout(1e4)
3416
+ });
3417
+ if (!res.ok)
3418
+ return null;
3419
+ return await res.json();
3420
+ } catch {
3421
+ return null;
3422
+ }
3423
+ }
3424
+ async function ingestCodexQuota(db, verbose = false) {
3425
+ const auth = readCodexAuth();
3426
+ if (!auth) {
3427
+ if (verbose)
3428
+ console.log(" codex quota: skipped \u2014 no ~/.codex/auth.json or CODEX_OAUTH_TOKEN");
3429
+ return { snapshots: 0 };
3430
+ }
3431
+ if (auth.authMode === "api_key" || auth.authMode === "api") {
3432
+ if (verbose)
3433
+ console.log(" codex quota: skipped \u2014 API key mode (no subscription quota)");
3434
+ return { snapshots: 0 };
3435
+ }
3436
+ const today = new Date().toISOString().substring(0, 10);
3437
+ const prev = getIngestState(db, "codex", `quota-${today}`);
3438
+ if (prev)
3439
+ return { snapshots: 0 };
3440
+ const usage = await fetchCodexUsage(auth.token, auth.accountId);
3441
+ if (!usage) {
3442
+ if (verbose)
3443
+ console.log(" codex quota: wham/usage endpoint unavailable");
3444
+ return { snapshots: 0 };
3445
+ }
3446
+ const machineId = getMachineId();
3447
+ let snapshots = 0;
3448
+ const now = new Date().toISOString();
3449
+ const windows = [
3450
+ ["five_hour_utilization", usage.rate_limit?.primary_window],
3451
+ ["seven_day_utilization", usage.rate_limit?.secondary_window]
3452
+ ];
3453
+ for (const [metric, window] of windows) {
3454
+ if (window?.used_percent == null)
3455
+ continue;
3456
+ upsertUsageSnapshot(db, {
3457
+ agent: "codex",
3458
+ date: today,
3459
+ metric,
3460
+ value: window.used_percent,
3461
+ unit: "percent",
3462
+ machine_id: machineId
3463
+ });
3464
+ snapshots++;
3465
+ if (window.reset_at) {
3466
+ upsertUsageSnapshot(db, {
709
3467
  agent: "codex",
710
- project_path: projectPath,
711
- project_name: projectName,
712
- started_at: startedAt,
713
- ended_at: endedAt,
714
- total_cost_usd: costUsd,
715
- total_tokens: thread.tokens_used,
716
- request_count: 1
3468
+ date: today,
3469
+ metric: `${metric}_resets_at`,
3470
+ value: window.reset_at * 1000,
3471
+ unit: "epoch_ms",
3472
+ machine_id: machineId
717
3473
  });
718
- setIngestState(db, "codex", stateKey, "done");
719
- ingested++;
720
- if (verbose)
721
- console.log(`Codex session ${thread.id}: ${thread.tokens_used} tokens \u2192 $${costUsd.toFixed(4)}`);
3474
+ snapshots++;
722
3475
  }
723
- } finally {
724
- codexDb?.close();
725
3476
  }
726
- return { sessions: ingested };
3477
+ if (usage.credits?.balance != null) {
3478
+ upsertUsageSnapshot(db, {
3479
+ agent: "codex",
3480
+ date: today,
3481
+ metric: "credits_balance_usd",
3482
+ value: usage.credits.balance,
3483
+ unit: "usd",
3484
+ machine_id: machineId
3485
+ });
3486
+ snapshots++;
3487
+ }
3488
+ const monthlyFee = planMonthlyFee(usage.plan_type);
3489
+ upsertSubscription(db, {
3490
+ id: "openai-codex-oauth",
3491
+ provider: "openai",
3492
+ agent: "codex",
3493
+ plan: usage.plan_type ?? "chatgpt_plus",
3494
+ monthly_fee_usd: monthlyFee,
3495
+ included_usage_usd: monthlyFee,
3496
+ billing_cycle_start: null,
3497
+ reset_policy: "monthly",
3498
+ active: 1,
3499
+ created_at: now,
3500
+ updated_at: now
3501
+ });
3502
+ setIngestState(db, "codex", `quota-${today}`, now);
3503
+ if (verbose)
3504
+ console.log(` codex quota: ${snapshots} snapshots (${usage.plan_type ?? "unknown plan"})`);
3505
+ return { snapshots };
3506
+ }
3507
+
3508
+ // src/lib/sync-all.ts
3509
+ init_database();
3510
+
3511
+ // src/lib/cloud-sync.ts
3512
+ init_database();
3513
+
3514
+ // src/lib/package-metadata.ts
3515
+ import { readFileSync as readFileSync8 } from "fs";
3516
+ var cachedMetadata = null;
3517
+ function getPackageMetadata() {
3518
+ if (cachedMetadata)
3519
+ return cachedMetadata;
3520
+ const raw = readFileSync8(new URL("../../package.json", import.meta.url), "utf8");
3521
+ const parsed = JSON.parse(raw);
3522
+ cachedMetadata = {
3523
+ name: parsed.name ?? "@hasna/economy",
3524
+ version: parsed.version ?? "0.0.0"
3525
+ };
3526
+ return cachedMetadata;
3527
+ }
3528
+ var packageMetadata = getPackageMetadata();
3529
+
3530
+ // src/lib/cloud-sync.ts
3531
+ var CLOUD_TABLES = [
3532
+ "requests",
3533
+ "sessions",
3534
+ "projects",
3535
+ "budgets",
3536
+ "goals",
3537
+ "model_pricing",
3538
+ "billing_daily",
3539
+ "subscriptions",
3540
+ "usage_snapshots",
3541
+ "savings_daily",
3542
+ "machines",
3543
+ "ingest_state"
3544
+ ];
3545
+ function getCloudDatabaseUrl() {
3546
+ return process.env["ECONOMY_CLOUD_DATABASE_URL"] ?? process.env["HASNA_ECONOMY_CLOUD_DATABASE_URL"] ?? null;
3547
+ }
3548
+ function isCloudAutoEnabled() {
3549
+ return process.env["ECONOMY_CLOUD_AUTO"] === "1" || process.env["ECONOMY_CLOUD_AUTO"] === "true";
3550
+ }
3551
+ function getCloudPullIntervalMinutes() {
3552
+ const raw = process.env["ECONOMY_CLOUD_PULL_INTERVAL"];
3553
+ if (!raw)
3554
+ return 15;
3555
+ const n = Number(raw);
3556
+ return Number.isFinite(n) && n > 0 ? n : 15;
3557
+ }
3558
+ async function getCloudPg() {
3559
+ const url = getCloudDatabaseUrl();
3560
+ if (!url) {
3561
+ throw new Error("Missing ECONOMY_CLOUD_DATABASE_URL (or HASNA_ECONOMY_CLOUD_DATABASE_URL)");
3562
+ }
3563
+ const { PgAdapterAsync } = await import("@hasna/cloud");
3564
+ return new PgAdapterAsync(url);
3565
+ }
3566
+ async function runCloudMigrations(cloud) {
3567
+ const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
3568
+ for (const sql of PG_MIGRATIONS2) {
3569
+ await cloud.run(sql);
3570
+ }
3571
+ }
3572
+ async function cloudPush(opts) {
3573
+ const { syncPush, SqliteAdapter } = await import("@hasna/cloud");
3574
+ const cloud = await getCloudPg();
3575
+ const local = new SqliteAdapter(getDbPath());
3576
+ await runCloudMigrations(cloud);
3577
+ const tables = opts?.tables ?? [...CLOUD_TABLES];
3578
+ const results = await syncPush(local, cloud, { tables, conflictColumn: "updated_at" });
3579
+ const rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3580
+ touchMachineRegistry(local, "push");
3581
+ local.close();
3582
+ await cloud.close();
3583
+ return { rows, machine: getMachineId() };
3584
+ }
3585
+ async function cloudPull(opts) {
3586
+ const { syncPull, SqliteAdapter } = await import("@hasna/cloud");
3587
+ const cloud = await getCloudPg();
3588
+ const local = new SqliteAdapter(getDbPath());
3589
+ await runCloudMigrations(cloud);
3590
+ const tables = opts?.tables ?? [...CLOUD_TABLES];
3591
+ const results = await syncPull(cloud, local, { tables, conflictColumn: "updated_at" });
3592
+ const rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3593
+ touchMachineRegistry(local, "pull");
3594
+ local.close();
3595
+ await cloud.close();
3596
+ setLastCloudPull();
3597
+ return { rows, machine: getMachineId() };
3598
+ }
3599
+ function setLastCloudPull(at = new Date().toISOString()) {
3600
+ const db = openDatabase();
3601
+ db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES ('cloud', 'last_pull_at', ?)`).run(at);
3602
+ }
3603
+ function getLastCloudPull() {
3604
+ const db = openDatabase();
3605
+ const row = db.prepare(`SELECT value FROM ingest_state WHERE source = 'cloud' AND key = 'last_pull_at'`).get();
3606
+ return row?.value ?? null;
3607
+ }
3608
+ function shouldPullFromCloud() {
3609
+ if (!getCloudDatabaseUrl())
3610
+ return false;
3611
+ const last = getLastCloudPull();
3612
+ if (!last)
3613
+ return true;
3614
+ const ageMs = Date.now() - new Date(last).getTime();
3615
+ return ageMs > getCloudPullIntervalMinutes() * 60000;
3616
+ }
3617
+ async function maybePullFromCloud() {
3618
+ if (!shouldPullFromCloud())
3619
+ return false;
3620
+ try {
3621
+ await cloudPull();
3622
+ return true;
3623
+ } catch {
3624
+ return false;
3625
+ }
3626
+ }
3627
+ async function maybePushAfterIngest() {
3628
+ if (!isCloudAutoEnabled() || !getCloudDatabaseUrl())
3629
+ return false;
3630
+ try {
3631
+ await cloudPush();
3632
+ return true;
3633
+ } catch {
3634
+ return false;
3635
+ }
3636
+ }
3637
+ function touchMachineRegistry(db, direction) {
3638
+ const now = new Date().toISOString();
3639
+ const machine = getMachineId();
3640
+ db.prepare(`
3641
+ INSERT INTO machines (machine_id, hostname, last_seen_at, last_push_at, last_pull_at, economy_version, updated_at)
3642
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3643
+ ON CONFLICT(machine_id) DO UPDATE SET
3644
+ hostname = excluded.hostname,
3645
+ last_seen_at = excluded.last_seen_at,
3646
+ last_push_at = CASE WHEN ? = 'push' THEN excluded.last_push_at ELSE machines.last_push_at END,
3647
+ last_pull_at = CASE WHEN ? = 'pull' THEN excluded.last_pull_at ELSE machines.last_pull_at END,
3648
+ economy_version = excluded.economy_version,
3649
+ updated_at = excluded.updated_at
3650
+ `).run(machine, machine, now, direction === "push" ? now : null, direction === "pull" ? now : null, packageMetadata.version, now, direction, direction);
3651
+ }
3652
+
3653
+ // src/lib/sync-all.ts
3654
+ async function syncAll(db, opts = {}) {
3655
+ const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes);
3656
+ const all = !anySpecific;
3657
+ await maybePullFromCloud();
3658
+ const result = { deduped: 0, cloudPulled: false, cloudPushed: false };
3659
+ if (all || opts.claude) {
3660
+ result.claude = await ingestClaude(db, opts.verbose);
3661
+ result.claudeQuota = await ingestClaudeQuota(db, opts.verbose);
3662
+ }
3663
+ if (all || opts.takumi)
3664
+ result.takumi = await ingestTakumi(db, opts.verbose);
3665
+ if (all || opts.codex) {
3666
+ result.codex = await ingestCodex(db, opts.verbose);
3667
+ result.codexQuota = await ingestCodexQuota(db, opts.verbose);
3668
+ }
3669
+ if (all || opts.gemini)
3670
+ result.gemini = await ingestGemini(db, opts.verbose);
3671
+ if (all || opts.opencode)
3672
+ result.opencode = await ingestOpenCode(db, opts.verbose);
3673
+ if (all || opts.cursor)
3674
+ result.cursor = await ingestCursor(db, opts.verbose);
3675
+ if (all || opts.pi)
3676
+ result.pi = await ingestPi(db, opts.verbose);
3677
+ if (all || opts.hermes)
3678
+ result.hermes = await ingestHermes(db, opts.verbose);
3679
+ result.deduped = dedupeRequests(db);
3680
+ result.cloudPushed = await maybePushAfterIngest();
3681
+ return result;
3682
+ }
3683
+
3684
+ // src/lib/billing-diff.ts
3685
+ init_database();
3686
+ var PROVIDER_TO_AGENT = {
3687
+ anthropic: "claude",
3688
+ openai: "codex",
3689
+ gemini: "gemini",
3690
+ google: "gemini"
3691
+ };
3692
+ function queryBillingDiff(db, period, thresholdPct = 15) {
3693
+ const estimated = querySummary(db, period, undefined, true);
3694
+ const actual = queryBillingSummary(db, period);
3695
+ const delta = estimated.total_usd - actual.total_usd;
3696
+ const deltaPct = actual.total_usd > 0 ? delta / actual.total_usd * 100 : 0;
3697
+ const agentRows = db.prepare(`
3698
+ SELECT agent, COALESCE(SUM(cost_usd), 0) as estimated_usd
3699
+ FROM requests
3700
+ WHERE ${periodWhere3(period, "timestamp")}
3701
+ GROUP BY agent
3702
+ `).all();
3703
+ const by_agent = agentRows.map((row) => {
3704
+ const provider = Object.entries(PROVIDER_TO_AGENT).find(([, a]) => a === row.agent)?.[0];
3705
+ const actualUsd = provider ? actual.by_provider[provider] ?? 0 : 0;
3706
+ const rowDelta = row.estimated_usd - actualUsd;
3707
+ const rowPct = actualUsd > 0 ? rowDelta / actualUsd * 100 : 0;
3708
+ return {
3709
+ agent: row.agent,
3710
+ estimated_usd: row.estimated_usd,
3711
+ actual_usd: actualUsd,
3712
+ delta_usd: rowDelta,
3713
+ delta_pct: rowPct
3714
+ };
3715
+ }).sort((a, b) => Math.abs(b.delta_usd) - Math.abs(a.delta_usd));
3716
+ return {
3717
+ period,
3718
+ estimated_usd: estimated.total_usd,
3719
+ actual_usd: actual.total_usd,
3720
+ delta_usd: delta,
3721
+ delta_pct: deltaPct,
3722
+ threshold_pct: thresholdPct,
3723
+ is_alert: Math.abs(deltaPct) > thresholdPct,
3724
+ by_agent,
3725
+ by_provider: actual.by_provider
3726
+ };
3727
+ }
3728
+ function periodWhere3(period, column) {
3729
+ switch (period) {
3730
+ case "today":
3731
+ return `DATE(${column}) = DATE('now')`;
3732
+ case "yesterday":
3733
+ return `DATE(${column}) = DATE('now', '-1 day')`;
3734
+ case "week":
3735
+ return `${column} >= DATE('now', 'weekday 0', '-7 days')`;
3736
+ case "month":
3737
+ return `${column} >= DATE('now', 'start of month')`;
3738
+ case "year":
3739
+ return `${column} >= DATE('now', 'start of year')`;
3740
+ case "all":
3741
+ return "1=1";
3742
+ }
3743
+ }
3744
+
3745
+ // src/server/serve.ts
3746
+ init_database();
3747
+
3748
+ // src/lib/serve-auth.ts
3749
+ function getServeApiToken() {
3750
+ return process.env["ECONOMY_API_TOKEN"] ?? process.env["HASNA_ECONOMY_API_TOKEN"];
3751
+ }
3752
+ function getServeBindHost() {
3753
+ const explicit = process.env["ECONOMY_BIND"] ?? process.env["ECONOMY_HOST"];
3754
+ if (explicit)
3755
+ return explicit;
3756
+ return getServeApiToken() ? "127.0.0.1" : "0.0.0.0";
3757
+ }
3758
+ function isAuthorizedRequest(req, path) {
3759
+ const token = getServeApiToken();
3760
+ if (!token)
3761
+ return true;
3762
+ if (path === "/health")
3763
+ return true;
3764
+ const auth = req.headers.get("Authorization");
3765
+ if (auth === `Bearer ${token}`)
3766
+ return true;
3767
+ if (req.headers.get("X-Economy-Token") === token)
3768
+ return true;
3769
+ return false;
727
3770
  }
728
3771
 
729
3772
  // src/server/serve.ts
730
- init_pricing();
731
3773
  import { randomUUID } from "crypto";
3774
+ import { existsSync as existsSync11 } from "fs";
3775
+ import { resolve, sep } from "path";
732
3776
  var CORS = {
733
3777
  "Access-Control-Allow-Origin": "*",
734
3778
  "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
735
- "Access-Control-Allow-Headers": "Content-Type"
3779
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Economy-Token"
736
3780
  };
3781
+ var AGENT_ERROR = `agent must be one of: ${AGENTS.join(", ")}`;
3782
+ var SYNC_SOURCES = ["all", ...AGENTS];
3783
+ var DEFAULT_DASHBOARD_DIR = new URL("../../dashboard/dist", import.meta.url).pathname;
737
3784
  function json(data, status = 200) {
738
3785
  return new Response(JSON.stringify(data), {
739
3786
  status,
@@ -746,6 +3793,70 @@ function ok(data, meta) {
746
3793
  function err(message, status = 400) {
747
3794
  return json({ error: message }, status);
748
3795
  }
3796
+ function normalizeBudgetPeriod(value) {
3797
+ switch (value) {
3798
+ case "day":
3799
+ case "daily":
3800
+ return "daily";
3801
+ case "week":
3802
+ case "weekly":
3803
+ return "weekly";
3804
+ case "month":
3805
+ case "monthly":
3806
+ default:
3807
+ return "monthly";
3808
+ }
3809
+ }
3810
+ function finiteNumber(value) {
3811
+ const n = Number(value);
3812
+ return Number.isFinite(n) ? n : null;
3813
+ }
3814
+ async function jsonBody(req) {
3815
+ const body = await req.json().catch(() => null);
3816
+ return body && typeof body === "object" && !Array.isArray(body) ? body : null;
3817
+ }
3818
+ function optionalString(value) {
3819
+ return typeof value === "string" ? value : null;
3820
+ }
3821
+ function optionalAgent(value) {
3822
+ if (value == null || value === "")
3823
+ return null;
3824
+ return typeof value === "string" && AGENTS.includes(value) ? value : undefined;
3825
+ }
3826
+ function stringArray(value) {
3827
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
3828
+ }
3829
+ function dashboardPath(root, pathname) {
3830
+ let decoded;
3831
+ try {
3832
+ decoded = decodeURIComponent(pathname);
3833
+ } catch {
3834
+ return null;
3835
+ }
3836
+ const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
3837
+ const rootPath = resolve(root);
3838
+ const filePath = resolve(rootPath, relativePath);
3839
+ return filePath === rootPath || filePath.startsWith(rootPath + sep) ? filePath : null;
3840
+ }
3841
+ function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
3842
+ return async function fetch2(req) {
3843
+ const url = new URL(req.url);
3844
+ if (url.pathname.startsWith("/api") || url.pathname === "/health") {
3845
+ return apiHandler(req);
3846
+ }
3847
+ if (existsSync11(dashboardDir)) {
3848
+ const filePath = dashboardPath(dashboardDir, url.pathname);
3849
+ if (filePath && existsSync11(filePath)) {
3850
+ return new Response(Bun.file(filePath));
3851
+ }
3852
+ const indexPath = dashboardPath(dashboardDir, "/");
3853
+ if (indexPath && existsSync11(indexPath)) {
3854
+ return new Response(Bun.file(indexPath));
3855
+ }
3856
+ }
3857
+ return apiHandler(req);
3858
+ };
3859
+ }
749
3860
  function applyFields(obj, fields) {
750
3861
  if (!fields || fields.length === 0)
751
3862
  return obj;
@@ -758,11 +3869,26 @@ function createHandler(db) {
758
3869
  const method = req.method;
759
3870
  if (method === "OPTIONS")
760
3871
  return new Response(null, { status: 204, headers: CORS });
3872
+ if (!isAuthorizedRequest(req, path))
3873
+ return err("Unauthorized", 401);
761
3874
  if (path === "/health")
762
3875
  return ok({ status: "ok", ts: new Date().toISOString() });
763
3876
  if (path === "/api/summary" && method === "GET") {
764
3877
  const period = url.searchParams.get("period") ?? "today";
765
- return ok(querySummary(db, period));
3878
+ const machine = url.searchParams.get("machine") ?? undefined;
3879
+ return ok(querySummary(db, period, machine));
3880
+ }
3881
+ if (path === "/api/machines" && method === "GET") {
3882
+ return ok(listMachines(db), { current_machine: getMachineId() });
3883
+ }
3884
+ if (path === "/api/fleet" && method === "GET") {
3885
+ const period = url.searchParams.get("period") ?? "month";
3886
+ return ok({
3887
+ summary: querySummary(db, period, undefined, true),
3888
+ machines: listMachines(db),
3889
+ registry: listMachineRegistry(db),
3890
+ current_machine: getMachineId()
3891
+ });
766
3892
  }
767
3893
  if (path === "/api/daily" && method === "GET") {
768
3894
  const days = Number(url.searchParams.get("days") ?? 30);
@@ -771,12 +3897,24 @@ function createHandler(db) {
771
3897
  if (path === "/api/sessions" && method === "GET") {
772
3898
  const agent = url.searchParams.get("agent");
773
3899
  const project = url.searchParams.get("project") ?? undefined;
3900
+ const search = url.searchParams.get("search") ?? undefined;
3901
+ const machine = url.searchParams.get("machine") ?? undefined;
3902
+ const account = url.searchParams.get("account") ?? undefined;
774
3903
  const limit = Number(url.searchParams.get("limit") ?? 50);
775
3904
  const offset = Number(url.searchParams.get("offset") ?? 0);
776
3905
  const since = url.searchParams.get("since") ?? undefined;
777
3906
  const fieldsParam = url.searchParams.get("fields");
778
3907
  const fields = fieldsParam ? fieldsParam.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
779
- const sessions = querySessions(db, { agent: agent ?? undefined, project, limit, offset, since });
3908
+ const sessions = querySessions(db, {
3909
+ agent: agent ?? undefined,
3910
+ project,
3911
+ search,
3912
+ machine,
3913
+ account,
3914
+ limit,
3915
+ offset,
3916
+ since
3917
+ });
780
3918
  return ok(fields ? sessions.map((s) => applyFields(s, fields)) : sessions, { limit, offset });
781
3919
  }
782
3920
  if (path === "/api/top" && method === "GET") {
@@ -787,49 +3925,112 @@ function createHandler(db) {
787
3925
  if (path === "/api/models" && method === "GET") {
788
3926
  return ok(queryModelBreakdown(db));
789
3927
  }
3928
+ if (path === "/api/billing" && method === "GET") {
3929
+ const period = url.searchParams.get("period") ?? "month";
3930
+ return ok(queryBillingSummary(db, period));
3931
+ }
3932
+ if (path === "/api/billing/diff" && method === "GET") {
3933
+ const period = url.searchParams.get("period") ?? "month";
3934
+ const threshold = Number(url.searchParams.get("threshold") ?? 15);
3935
+ return ok(queryBillingDiff(db, period, Number.isFinite(threshold) ? threshold : 15));
3936
+ }
3937
+ if (path === "/api/billing/sync" && method === "POST") {
3938
+ const body = await jsonBody(req) ?? {};
3939
+ const days = Number(body["days"] ?? 31);
3940
+ if (!Number.isFinite(days) || days <= 0 || days > 366)
3941
+ return err("days must be between 1 and 366");
3942
+ const providers = Array.isArray(body["providers"]) ? body["providers"] : ["anthropic", "openai", "gemini"];
3943
+ const allowedProviders = new Set(["anthropic", "openai", "gemini"]);
3944
+ if (providers.some((provider) => !allowedProviders.has(provider)))
3945
+ return err("invalid billing provider");
3946
+ const results = {};
3947
+ const { syncAnthropicBilling: syncAnthropicBilling2, syncOpenAIBilling: syncOpenAIBilling2, syncGeminiBilling: syncGeminiBilling2 } = await Promise.resolve().then(() => (init_billing(), exports_billing));
3948
+ async function capture(provider, fn) {
3949
+ try {
3950
+ results[provider] = await fn();
3951
+ } catch (e) {
3952
+ results[provider] = { error: e instanceof Error ? e.message : String(e) };
3953
+ }
3954
+ }
3955
+ if (providers.includes("anthropic"))
3956
+ await capture("anthropic", () => syncAnthropicBilling2(db, { days }));
3957
+ if (providers.includes("openai"))
3958
+ await capture("openai", () => syncOpenAIBilling2(db, { days }));
3959
+ if (providers.includes("gemini"))
3960
+ await capture("gemini", () => syncGeminiBilling2(db, { days }));
3961
+ return ok(results);
3962
+ }
790
3963
  if (path === "/api/projects" && method === "GET") {
791
- return ok(queryProjectBreakdown(db));
3964
+ const period = url.searchParams.get("period") ?? "all";
3965
+ return ok(queryProjectBreakdown(db, period));
3966
+ }
3967
+ if (path === "/api/accounts" && method === "GET") {
3968
+ const period = url.searchParams.get("period") ?? "all";
3969
+ return ok(queryAccountBreakdown(db, period));
792
3970
  }
793
3971
  if (path === "/api/breakdown" && method === "GET") {
794
3972
  const by = url.searchParams.get("by") ?? "model";
795
- return ok(by === "project" ? queryProjectBreakdown(db) : queryModelBreakdown(db));
3973
+ const period = url.searchParams.get("period") ?? "all";
3974
+ if (by === "project")
3975
+ return ok(queryProjectBreakdown(db, period));
3976
+ if (by === "agent")
3977
+ return ok(queryAgentBreakdown(db, period));
3978
+ if (by === "account")
3979
+ return ok(queryAccountBreakdown(db, period));
3980
+ return ok(queryModelBreakdown(db));
796
3981
  }
797
3982
  if (path === "/api/budgets" && method === "GET") {
798
3983
  return ok(getBudgetStatuses(db));
799
3984
  }
800
3985
  if (path === "/api/budgets" && method === "POST") {
801
- const body = await req.json();
3986
+ const body = await jsonBody(req);
3987
+ if (!body)
3988
+ return err("invalid JSON body");
3989
+ const limitUsd = finiteNumber(body["limit_usd"]);
3990
+ const alertAtPercent = finiteNumber(body["alert_at_percent"] ?? 80);
3991
+ if (limitUsd == null || limitUsd <= 0)
3992
+ return err("limit_usd must be a positive number");
3993
+ if (alertAtPercent == null || alertAtPercent <= 0 || alertAtPercent > 100)
3994
+ return err("alert_at_percent must be between 1 and 100");
3995
+ const agent = optionalAgent(body["agent"]);
3996
+ if (agent === undefined)
3997
+ return err(AGENT_ERROR);
802
3998
  const now = new Date().toISOString();
803
- upsertBudget(db, {
3999
+ const budget = {
804
4000
  id: randomUUID(),
805
4001
  project_path: body["project_path"] ?? null,
806
- agent: body["agent"] ?? null,
807
- period: body["period"] ?? "monthly",
808
- limit_usd: Number(body["limit_usd"]),
809
- alert_at_percent: Number(body["alert_at_percent"] ?? 80),
4002
+ agent,
4003
+ period: normalizeBudgetPeriod(body["period"]),
4004
+ limit_usd: limitUsd,
4005
+ alert_at_percent: alertAtPercent,
810
4006
  created_at: now,
811
4007
  updated_at: now
812
- });
813
- return ok({ ok: true });
4008
+ };
4009
+ upsertBudget(db, budget);
4010
+ return ok(getBudgetStatuses(db).find((b) => b.id === budget.id) ?? budget);
814
4011
  }
815
4012
  const budgetMatch = path.match(/^\/api\/budgets\/(.+)$/);
816
4013
  if (budgetMatch && method === "DELETE") {
817
- deleteBudget(db, budgetMatch[1]);
4014
+ deleteBudget(db, decodeURIComponent(budgetMatch[1]));
818
4015
  return ok({ ok: true });
819
4016
  }
820
4017
  if (path === "/api/project-registry" && method === "GET") {
821
4018
  return ok(listProjects(db));
822
4019
  }
823
4020
  if (path === "/api/project-registry" && method === "POST") {
824
- const body = await req.json();
825
- const { basename: basename3 } = await import("path");
826
- const projPath = body["path"];
4021
+ const body = await jsonBody(req);
4022
+ if (!body)
4023
+ return err("invalid JSON body");
4024
+ const { basename: basename4 } = await import("path");
4025
+ const projPath = optionalString(body["path"])?.trim();
4026
+ if (!projPath)
4027
+ return err("path is required");
827
4028
  upsertProject(db, {
828
4029
  id: randomUUID(),
829
4030
  path: projPath,
830
- name: body["name"] ?? basename3(projPath),
831
- description: body["description"] ?? null,
832
- tags: body["tags"] ?? [],
4031
+ name: optionalString(body["name"]) ?? basename4(projPath),
4032
+ description: optionalString(body["description"]),
4033
+ tags: stringArray(body["tags"]),
833
4034
  created_at: new Date().toISOString()
834
4035
  });
835
4036
  return ok({ ok: true });
@@ -843,16 +4044,33 @@ function createHandler(db) {
843
4044
  return ok(listModelPricing(db));
844
4045
  }
845
4046
  if (path === "/api/pricing" && method === "POST") {
846
- const body = await req.json();
847
- upsertModelPricing(db, {
848
- model: body["model"],
849
- input_per_1m: Number(body["input_per_1m"]),
850
- output_per_1m: Number(body["output_per_1m"]),
851
- cache_read_per_1m: Number(body["cache_read_per_1m"] ?? 0),
852
- cache_write_per_1m: Number(body["cache_write_per_1m"] ?? 0),
4047
+ const body = await jsonBody(req);
4048
+ if (!body)
4049
+ return err("invalid JSON body");
4050
+ const model = String(body["model"] ?? "").trim();
4051
+ if (!model)
4052
+ return err("model is required");
4053
+ const input = finiteNumber(body["input_per_1m"]);
4054
+ const output = finiteNumber(body["output_per_1m"]);
4055
+ const cacheRead = finiteNumber(body["cache_read_per_1m"] ?? 0);
4056
+ const cacheWrite = finiteNumber(body["cache_write_per_1m"] ?? 0);
4057
+ const cacheWrite1h = finiteNumber(body["cache_write_1h_per_1m"] ?? 0);
4058
+ const cacheStorage = finiteNumber(body["cache_storage_per_1m_hour"] ?? 0);
4059
+ if ([input, output, cacheRead, cacheWrite, cacheWrite1h, cacheStorage].some((v) => v == null || v < 0)) {
4060
+ return err("pricing values must be non-negative numbers");
4061
+ }
4062
+ const pricing = {
4063
+ model,
4064
+ input_per_1m: input,
4065
+ output_per_1m: output,
4066
+ cache_read_per_1m: cacheRead,
4067
+ cache_write_per_1m: cacheWrite,
4068
+ cache_write_1h_per_1m: cacheWrite1h,
4069
+ cache_storage_per_1m_hour: cacheStorage,
853
4070
  updated_at: new Date().toISOString()
854
- });
855
- return ok({ ok: true });
4071
+ };
4072
+ upsertModelPricing(db, pricing);
4073
+ return ok(pricing);
856
4074
  }
857
4075
  const pricingMatch = path.match(/^\/api\/pricing\/(.+)$/);
858
4076
  if (pricingMatch && method === "DELETE") {
@@ -860,18 +4078,43 @@ function createHandler(db) {
860
4078
  return ok({ ok: true });
861
4079
  }
862
4080
  if (path === "/api/sync" && method === "POST") {
863
- const body = await req.json().catch(() => ({}));
4081
+ const body = await jsonBody(req) ?? {};
864
4082
  const sources = body["sources"] ?? "all";
4083
+ if (!SYNC_SOURCES.includes(sources))
4084
+ return err("invalid sync source");
865
4085
  const results = {};
866
- if (sources === "all" || sources === "claude")
867
- results["claude"] = await ingestClaude(db);
868
- if (sources === "all" || sources === "codex")
869
- results["codex"] = await ingestCodex(db);
4086
+ if (sources === "all") {
4087
+ try {
4088
+ const { syncOpenProjectsRegistry: syncOpenProjectsRegistry2 } = await Promise.resolve().then(() => (init_open_projects(), exports_open_projects));
4089
+ results["projects"] = await syncOpenProjectsRegistry2(db);
4090
+ } catch {}
4091
+ }
4092
+ const selected = sources === "all" ? {} : { [sources]: true };
4093
+ const syncResult = await syncAll(db, selected);
4094
+ Object.assign(results, syncResult);
4095
+ try {
4096
+ const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
4097
+ await checkAndFireWebhooks2(db);
4098
+ } catch {}
870
4099
  return ok(results);
871
4100
  }
4101
+ if (path === "/api/usage" && method === "GET") {
4102
+ const period = url.searchParams.get("period") ?? "month";
4103
+ const agent = url.searchParams.get("agent") ?? undefined;
4104
+ const since = period === "month" ? new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().substring(0, 10) : undefined;
4105
+ return ok({
4106
+ snapshots: queryUsageSnapshots(db, { agent: agent && isAgent(agent) ? agent : undefined, since }),
4107
+ summary: querySummary(db, period, undefined, true)
4108
+ });
4109
+ }
4110
+ if (path === "/api/savings" && method === "GET") {
4111
+ const period = url.searchParams.get("period") ?? "month";
4112
+ const agent = url.searchParams.get("agent") ?? undefined;
4113
+ return ok(querySavingsSummary(db, period, agent && isAgent(agent) ? agent : undefined));
4114
+ }
872
4115
  const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
873
4116
  if (sessionRequestsMatch && method === "GET") {
874
- const sessionId = sessionRequestsMatch[1];
4117
+ const sessionId = decodeURIComponent(sessionRequestsMatch[1]);
875
4118
  const session = db.prepare(`SELECT * FROM sessions WHERE id = ? OR id LIKE ?`).get(sessionId, `${sessionId}%`);
876
4119
  if (!session)
877
4120
  return err("Session not found", 404);
@@ -882,59 +4125,97 @@ function createHandler(db) {
882
4125
  return ok(getGoalStatuses(db));
883
4126
  }
884
4127
  if (path === "/api/goals" && method === "POST") {
885
- const body = await req.json();
4128
+ const body = await jsonBody(req);
4129
+ if (!body)
4130
+ return err("invalid JSON body");
4131
+ const period = body["period"] ?? "month";
4132
+ if (!["day", "week", "month", "year"].includes(String(period)))
4133
+ return err("period must be day, week, month, or year");
4134
+ const limitUsd = finiteNumber(body["limit_usd"]);
4135
+ if (limitUsd == null || limitUsd <= 0)
4136
+ return err("limit_usd must be a positive number");
4137
+ const agent = optionalAgent(body["agent"]);
4138
+ if (agent === undefined)
4139
+ return err(AGENT_ERROR);
886
4140
  const now = new Date().toISOString();
887
- upsertGoal(db, {
4141
+ const goal = {
888
4142
  id: randomUUID(),
889
- period: body["period"] ?? "month",
890
- project_path: body["project_path"] ?? null,
891
- agent: body["agent"] ?? null,
892
- limit_usd: Number(body["limit_usd"]),
4143
+ period,
4144
+ project_path: optionalString(body["project_path"]),
4145
+ agent,
4146
+ limit_usd: limitUsd,
893
4147
  created_at: now,
894
4148
  updated_at: now
895
- });
896
- return ok({ ok: true });
4149
+ };
4150
+ upsertGoal(db, goal);
4151
+ return ok(getGoalStatuses(db).find((g) => g.id === goal.id) ?? goal);
897
4152
  }
898
4153
  const goalMatch = path.match(/^\/api\/goals\/(.+)$/);
899
4154
  if (goalMatch && method === "DELETE") {
900
- deleteGoal(db, goalMatch[1]);
4155
+ deleteGoal(db, decodeURIComponent(goalMatch[1]));
901
4156
  return ok({ ok: true });
902
4157
  }
903
4158
  return err("Not found", 404);
904
4159
  };
905
4160
  }
906
- function startServer(port = 3456) {
907
- const db = openDatabase();
4161
+ function startServer(port = 3456, options = {}) {
4162
+ const db = options.db ?? openDatabase();
908
4163
  ensurePricingSeeded(db);
909
4164
  const apiHandler = createHandler(db);
910
- const dashboardDir = new URL("../../dashboard/dist", import.meta.url).pathname;
911
- Bun.serve({
4165
+ const hostname2 = options.hostname ?? getServeBindHost();
4166
+ const server = Bun.serve({
912
4167
  port,
913
- async fetch(req) {
914
- const url = new URL(req.url);
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
- }
4168
+ hostname: hostname2,
4169
+ fetch: createServerFetch(apiHandler, options.dashboardDir)
934
4170
  });
935
- console.log(`economy-serve listening on http://localhost:${port}`);
4171
+ const address = `http://${hostname2 === "0.0.0.0" ? "localhost" : hostname2}:${server.port}`;
4172
+ const log = options.log ?? console.log;
4173
+ log(`economy-serve listening on ${address}`);
4174
+ return server;
936
4175
  }
937
4176
 
938
4177
  // src/server/index.ts
939
- var port = Number(process.env["ECONOMY_PORT"] ?? 3456);
940
- startServer(port);
4178
+ function printHelp() {
4179
+ console.log(`Usage: economy-serve [options]
4180
+
4181
+ REST API server for ${packageMetadata.name}
4182
+
4183
+ Options:
4184
+ -p, --port <port> Port to bind (default: ECONOMY_PORT or 3456)
4185
+ -V, --version output the version number
4186
+ -h, --help display help for command`);
4187
+ }
4188
+ function resolvePort(argv) {
4189
+ for (let i = 0;i < argv.length; i++) {
4190
+ const arg = argv[i];
4191
+ if (arg === "--port" || arg === "-p") {
4192
+ const raw = argv[i + 1];
4193
+ if (!raw)
4194
+ throw new Error(`Invalid port: ${raw ?? ""}`);
4195
+ return parsePort(raw, "port");
4196
+ }
4197
+ }
4198
+ return parsePort(process.env["ECONOMY_PORT"] ?? "3456", "ECONOMY_PORT");
4199
+ }
4200
+ function parsePort(raw, label) {
4201
+ const value = Number(raw);
4202
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
4203
+ throw new Error(`Invalid ${label}: ${raw}`);
4204
+ }
4205
+ return value;
4206
+ }
4207
+ var args = process.argv.slice(2);
4208
+ if (args.includes("--help") || args.includes("-h")) {
4209
+ printHelp();
4210
+ process.exit(0);
4211
+ }
4212
+ if (args.includes("--version") || args.includes("-V")) {
4213
+ console.log(packageMetadata.version);
4214
+ process.exit(0);
4215
+ }
4216
+ try {
4217
+ startServer(resolvePort(args));
4218
+ } catch (error) {
4219
+ console.error(error instanceof Error ? error.message : String(error));
4220
+ process.exit(1);
4221
+ }