llm_cost_tracker 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +58 -221
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -16,10 +16,31 @@
16
16
  --lct-danger: #dc2626;
17
17
  --lct-danger-soft: #fee2e2;
18
18
  --lct-danger-border: #fca5a5;
19
+ --lct-warning-copy: #9a3412;
20
+ --lct-warning-strong: #7c2d12;
21
+ --lct-danger-copy: #b91c1c;
22
+ --lct-danger-strong: #991b1b;
19
23
  --lct-row-hover: #f8fafc;
24
+ --lct-th-bg: #fbfcfe;
25
+ --lct-toolbar-bg: rgba(255, 255, 255, 0.72);
26
+ --lct-toolbar-border: #e7ecf3;
27
+ --lct-border-strong: #d4dae2;
28
+ --lct-chart-secondary: rgba(10, 37, 64, 0.42);
29
+ --lct-chart-marker: rgba(10, 37, 64, 0.45);
30
+ --lct-chip-remove: rgba(10, 37, 64, 0.58);
20
31
  --lct-shadow: 0 1px 2px rgba(10, 37, 64, 0.05), 0 1px 1px rgba(10, 37, 64, 0.03);
32
+ --lct-shadow-sm: 0 1px 2px rgba(10, 37, 64, 0.06);
33
+ --lct-shadow-md: 0 4px 12px rgba(10, 37, 64, 0.08), 0 1px 2px rgba(10, 37, 64, 0.04);
34
+ --lct-shadow-lg: 0 12px 32px rgba(10, 37, 64, 0.12), 0 2px 6px rgba(10, 37, 64, 0.05);
21
35
  --lct-focus-ring: 0 0 0 3px rgba(99, 91, 255, 0.25);
22
36
 
37
+ --sp-1: 4px;
38
+ --sp-2: 8px;
39
+ --sp-3: 12px;
40
+ --sp-4: 16px;
41
+ --sp-5: 24px;
42
+ --sp-6: 32px;
43
+
23
44
  --fs-2xs: 10px;
24
45
  --fs-xs: 11px;
25
46
  --fs-sm: 13px;
@@ -34,6 +55,53 @@
34
55
  --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
35
56
  }
36
57
 
58
+ @media (prefers-color-scheme: dark) {
59
+ :root {
60
+ --lct-bg: #0b1220;
61
+ --lct-panel: #111a2e;
62
+ --lct-surface: #1a253c;
63
+ --lct-border: #233149;
64
+ --lct-text: #e6ecf5;
65
+ --lct-muted: #8a98b3;
66
+ --lct-accent: #8a82ff;
67
+ --lct-accent-hover: #a39cff;
68
+ --lct-accent-soft: rgba(138, 130, 255, 0.16);
69
+ --lct-success: #34d399;
70
+ --lct-success-soft: rgba(52, 211, 153, 0.16);
71
+ --lct-warning: #fbbf24;
72
+ --lct-warning-soft: rgba(251, 191, 36, 0.16);
73
+ --lct-warning-border: rgba(251, 191, 36, 0.4);
74
+ --lct-danger: #f87171;
75
+ --lct-danger-soft: rgba(248, 113, 113, 0.16);
76
+ --lct-danger-border: rgba(248, 113, 113, 0.4);
77
+ --lct-row-hover: #182238;
78
+ --lct-th-bg: #142036;
79
+ --lct-toolbar-bg: rgba(17, 26, 46, 0.78);
80
+ --lct-toolbar-border: #233149;
81
+ --lct-border-strong: #2f3f5c;
82
+ --lct-warning-copy: #fcd34d;
83
+ --lct-warning-strong: #fde68a;
84
+ --lct-danger-copy: #fca5a5;
85
+ --lct-danger-strong: #fecaca;
86
+ --lct-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 1px rgba(0, 0, 0, 0.25);
87
+ --lct-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
88
+ --lct-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.25);
89
+ --lct-shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
90
+ --lct-focus-ring: 0 0 0 3px rgba(138, 130, 255, 0.35);
91
+ --lct-chart-secondary: rgba(230, 236, 245, 0.32);
92
+ --lct-chart-marker: rgba(230, 236, 245, 0.45);
93
+ --lct-chip-remove: rgba(230, 236, 245, 0.58);
94
+ }
95
+
96
+ .lct-field select {
97
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%238a98b3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M2.5 4.5l3.5 3.5 3.5-3.5'/></svg>");
98
+ }
99
+
100
+ .lct-field input[type="date"]::-webkit-calendar-picker-indicator {
101
+ filter: invert(70%) sepia(8%) saturate(437%) hue-rotate(176deg) brightness(95%) contrast(86%);
102
+ }
103
+ }
104
+
37
105
  .lct-body { margin: 0; }
38
106
 
39
107
  .lct-app {
@@ -93,6 +161,9 @@
93
161
 
94
162
  .lct-panel-tight { padding: 16px 18px; }
95
163
 
164
+ .lct-panel + .lct-panel,
165
+ .lct-stat-grid + .lct-panel { margin-top: 16px; }
166
+
96
167
  .lct-banner {
97
168
  align-items: center;
98
169
  border-radius: 8px;
@@ -108,13 +179,13 @@
108
179
  .lct-banner-title { font-size: var(--fs-md); font-weight: 600; margin: 0; }
109
180
  .lct-banner-muted { color: var(--lct-muted); font-weight: 400; }
110
181
 
111
- .lct-banner-warning { background: var(--lct-warning-soft); border-color: var(--lct-warning-border); color: #7c2d12; }
182
+ .lct-banner-warning { background: var(--lct-warning-soft); border-color: var(--lct-warning-border); color: var(--lct-warning-strong); }
112
183
  .lct-banner-warning .lct-banner-muted,
113
- .lct-banner-warning .lct-banner-copy { color: #9a3412; }
184
+ .lct-banner-warning .lct-banner-copy { color: var(--lct-warning-copy); }
114
185
 
115
- .lct-banner-danger { background: var(--lct-danger-soft); border-color: var(--lct-danger-border); color: #991b1b; }
186
+ .lct-banner-danger { background: var(--lct-danger-soft); border-color: var(--lct-danger-border); color: var(--lct-danger-strong); }
116
187
  .lct-banner-danger .lct-banner-muted,
117
- .lct-banner-danger .lct-banner-copy { color: #b91c1c; }
188
+ .lct-banner-danger .lct-banner-copy { color: var(--lct-danger-copy); }
118
189
 
119
190
  .lct-muted { color: var(--lct-muted); }
120
191
 
@@ -268,16 +339,26 @@
268
339
  .lct-table tbody tr:last-child td { border-bottom: 0; }
269
340
 
270
341
  .lct-table th {
271
- background: #fbfcfe;
342
+ background: var(--lct-th-bg);
272
343
  color: var(--lct-muted);
273
344
  font-weight: 600;
274
345
  font-size: var(--fs-xs);
275
346
  letter-spacing: 0;
276
347
  text-transform: none;
348
+ position: sticky;
349
+ top: 0;
350
+ z-index: 1;
277
351
  }
278
352
 
353
+ .lct-table tbody tr {
354
+ transition: background-color 0.1s ease;
355
+ }
279
356
  .lct-table tbody tr:hover { background: var(--lct-row-hover); }
280
357
 
358
+ @media (prefers-reduced-motion: reduce) {
359
+ .lct-table tbody tr { transition: none; }
360
+ }
361
+
281
362
  .lct-table td:last-child,
282
363
  .lct-table th:last-child,
283
364
  .lct-calls-table td:last-child,
@@ -304,8 +385,10 @@
304
385
  .lct-stack-fill-input { background: var(--lct-accent); }
305
386
  .lct-stack-fill-cache-read { background: #22c55e; }
306
387
  .lct-stack-fill-cache-write { background: #f59e0b; }
307
- .lct-stack-fill-cache-write-1h { background: #a855f7; }
388
+ .lct-stack-fill-cache-write-extended { background: #a855f7; }
389
+ .lct-stack-fill-audio-input { background: #ec4899; }
308
390
  .lct-stack-fill-output { background: #0ea5e9; }
391
+ .lct-stack-fill-audio-output { background: #14b8a6; }
309
392
 
310
393
  .lct-budget { display: grid; gap: 10px; }
311
394
  .lct-budget-head { align-items: baseline; display: flex; gap: 10px; justify-content: space-between; }
@@ -318,7 +401,7 @@
318
401
  .lct-budget-of { color: var(--lct-muted); font-size: var(--fs-sm); font-weight: 500; }
319
402
  .lct-budget-percent { font-size: var(--fs-md); font-variant-numeric: tabular-nums; font-weight: 700; }
320
403
  .lct-budget-marker {
321
- border-left: 2px dashed rgba(10, 37, 64, 0.45);
404
+ border-left: 2px dashed var(--lct-chart-marker);
322
405
  bottom: 0;
323
406
  position: absolute;
324
407
  top: 0;
@@ -335,7 +418,7 @@
335
418
  }
336
419
  .lct-budget-projection strong { color: var(--lct-text); }
337
420
  .lct-budget-projection-status { font-weight: 600; }
338
- .lct-budget-projection-status--over { color: #9a3412; }
421
+ .lct-budget-projection-status--over { color: var(--lct-warning-copy); }
339
422
  .lct-budget-projection-status--under { color: var(--lct-muted); }
340
423
  .lct-budget-meta { color: var(--lct-muted); font-size: var(--fs-xs); }
341
424
 
@@ -344,7 +427,7 @@
344
427
  .lct-chart-line-secondary {
345
428
  fill: none;
346
429
  opacity: 0.75;
347
- stroke: rgba(10, 37, 64, 0.42);
430
+ stroke: var(--lct-chart-secondary);
348
431
  stroke-dasharray: 5 4;
349
432
  stroke-linejoin: round;
350
433
  stroke-linecap: round;
@@ -365,7 +448,7 @@
365
448
  .lct-chart-legend-compare { align-items: center; display: inline-flex; gap: 12px; }
366
449
  .lct-chart-key { align-items: center; display: inline-flex; gap: 6px; }
367
450
  .lct-chart-key-line { background: var(--lct-accent); border-radius: 999px; display: inline-block; height: 2px; width: 16px; }
368
- .lct-chart-key-line-secondary { background: rgba(10, 37, 64, 0.42); position: relative; }
451
+ .lct-chart-key-line-secondary { background: var(--lct-chart-secondary); position: relative; }
369
452
  .lct-chart-key-line-secondary::after {
370
453
  background: linear-gradient(90deg, transparent 40%, var(--lct-bg) 40%, var(--lct-bg) 60%, transparent 60%);
371
454
  content: "";
@@ -399,15 +482,21 @@
399
482
  .lct-tag-chip-more { background: transparent; border: 1px dashed var(--lct-border); color: var(--lct-muted); }
400
483
  .lct-tag-empty { color: var(--lct-muted); font-size: var(--fs-sm); font-style: italic; }
401
484
 
402
- .lct-empty { padding: 28px; text-align: center; }
403
- .lct-state-title { font-size: var(--fs-xl); font-weight: 600; margin: 0 0 8px; }
404
- .lct-state-copy { color: var(--lct-muted); margin: 0 auto; max-width: 640px; }
405
- .lct-state-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
485
+ .lct-empty {
486
+ padding: 56px 28px;
487
+ text-align: center;
488
+ background:
489
+ radial-gradient(ellipse 60% 50% at 50% 0%, rgba(99, 91, 255, 0.04), transparent 70%),
490
+ var(--lct-panel);
491
+ }
492
+ .lct-state-title { font-size: var(--fs-lg); font-weight: 600; margin: 0 0 6px; letter-spacing: -0.005em; }
493
+ .lct-state-copy { color: var(--lct-muted); margin: 0 auto; max-width: 480px; font-size: var(--fs-sm); line-height: 1.55; }
494
+ .lct-state-actions { display: flex; gap: 8px; justify-content: center; margin-top: 20px; }
406
495
 
407
496
  .lct-toolbar {
408
- background: rgba(255, 255, 255, 0.72);
497
+ background: var(--lct-toolbar-bg);
409
498
  backdrop-filter: blur(10px);
410
- border-color: #e7ecf3;
499
+ border-color: var(--lct-toolbar-border);
411
500
  box-shadow: none;
412
501
  padding: 16px;
413
502
  margin-bottom: 16px;
@@ -429,52 +518,124 @@
429
518
  .lct-toolbar-actions .lct-button,
430
519
  .lct-banner .lct-button { font-size: var(--fs-sm); height: 32px; padding: 0 12px; }
431
520
 
432
- .lct-filters { display: grid; gap: 8px; }
521
+ .lct-filters { display: block; }
433
522
 
434
- .lct-filter-row { align-items: end; display: grid; gap: 12px; grid-template-columns: 1fr; }
435
- .lct-filter-row-basic { grid-template-columns: 144px 144px 180px minmax(260px, 1fr) auto; }
436
- .lct-filter-row-with-sort { grid-template-columns: 144px 144px 180px minmax(240px, 1fr) 160px auto; }
523
+ .lct-filter-row {
524
+ align-items: flex-end;
525
+ display: flex;
526
+ flex-wrap: wrap;
527
+ gap: 12px;
528
+ }
437
529
 
438
530
  .lct-filter-actions {
439
- align-items: end;
531
+ align-items: flex-end;
440
532
  display: flex;
441
533
  flex-wrap: wrap;
442
534
  gap: 8px;
443
- justify-content: flex-start;
535
+ margin-left: auto;
444
536
  }
445
537
 
446
- .lct-field { display: inline-block; }
538
+ .lct-field {
539
+ display: flex;
540
+ flex: 1 1 160px;
541
+ flex-direction: column;
542
+ gap: 6px;
543
+ min-width: 140px;
544
+ }
447
545
 
448
546
  .lct-field input,
449
547
  .lct-field select {
450
- border: 0;
548
+ appearance: none;
549
+ -webkit-appearance: none;
550
+ -moz-appearance: none;
551
+ background: var(--lct-panel);
552
+ border: 1px solid var(--lct-border);
451
553
  border-radius: 8px;
554
+ box-sizing: border-box;
452
555
  color: var(--lct-text);
453
556
  font-family: inherit;
454
557
  font-size: var(--fs-md);
455
- background: var(--lct-panel);
456
- box-shadow: inset 0 0 0 1px var(--lct-border);
457
- box-sizing: border-box;
458
558
  height: var(--lct-control-height);
459
559
  line-height: 1.2;
460
560
  padding: 0 12px;
461
561
  width: 100%;
462
562
  }
463
563
 
464
- .lct-field input::placeholder { color: #8792a2; }
564
+ .lct-field select {
565
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23697386' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M2.5 4.5l3.5 3.5 3.5-3.5'/></svg>");
566
+ background-position: right 10px center;
567
+ background-repeat: no-repeat;
568
+ background-size: 12px 12px;
569
+ padding-right: 32px;
570
+ }
571
+
572
+ .lct-field input[type="date"] {
573
+ font-variant-numeric: tabular-nums;
574
+ padding-right: 8px;
575
+ }
576
+
577
+ .lct-field input[type="date"]::-webkit-calendar-picker-indicator {
578
+ cursor: pointer;
579
+ filter: invert(45%) sepia(8%) saturate(437%) hue-rotate(176deg) brightness(95%) contrast(86%);
580
+ opacity: 0.85;
581
+ }
582
+
583
+ .lct-field input::placeholder { color: var(--lct-muted); }
584
+
585
+ .lct-field input:hover,
586
+ .lct-field select:hover { border-color: var(--lct-border-strong); }
465
587
 
466
588
  .lct-field input:focus,
467
589
  .lct-field select:focus {
468
- box-shadow: inset 0 0 0 2px var(--lct-accent);
590
+ border-color: var(--lct-accent);
591
+ box-shadow: var(--lct-focus-ring);
469
592
  outline: none;
470
593
  }
471
594
 
595
+ .lct-results-toolbar {
596
+ align-items: center;
597
+ display: flex;
598
+ flex-wrap: wrap;
599
+ gap: 12px;
600
+ margin: -4px 0 8px;
601
+ }
602
+
603
+ .lct-results-toolbar .lct-pagination-per { order: 0; margin-right: auto; }
604
+ .lct-results-toolbar .lct-sort { order: 1; margin-left: auto; }
605
+
606
+ .lct-sort {
607
+ align-items: center;
608
+ background: var(--lct-surface);
609
+ border-radius: 6px;
610
+ display: inline-flex;
611
+ flex-wrap: wrap;
612
+ gap: 2px;
613
+ padding: 2px;
614
+ }
615
+
616
+ .lct-sort-option {
617
+ border-radius: 4px;
618
+ color: var(--lct-muted);
619
+ display: inline-block;
620
+ font-size: var(--fs-xs);
621
+ font-weight: 500;
622
+ padding: 4px 8px;
623
+ text-decoration: none;
624
+ }
625
+
626
+ .lct-sort-option:hover { background: var(--lct-panel); color: var(--lct-text); }
627
+ .lct-sort-option.is-active {
628
+ background: var(--lct-panel);
629
+ box-shadow: var(--lct-shadow);
630
+ color: var(--lct-text);
631
+ }
632
+
472
633
  .lct-button {
473
634
  align-items: center;
474
635
  background: var(--lct-accent);
475
636
  border: 1px solid var(--lct-accent);
476
637
  border-radius: 8px;
477
- box-shadow: 0 1px 2px rgba(10, 37, 64, 0.08);
638
+ box-shadow: var(--lct-shadow-sm);
478
639
  color: #ffffff;
479
640
  cursor: pointer;
480
641
  display: inline-flex;
@@ -486,17 +647,26 @@
486
647
  height: var(--lct-control-height);
487
648
  padding: 0 14px;
488
649
  text-decoration: none;
489
- transition: background-color 0.12s ease, border-color 0.12s ease;
650
+ transition: background-color 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease, transform 0.08s ease;
490
651
  white-space: nowrap;
491
652
  }
492
653
 
493
- .lct-button:hover { background: var(--lct-accent-hover); border-color: var(--lct-accent-hover); }
654
+ .lct-button:hover {
655
+ background: var(--lct-accent-hover);
656
+ border-color: var(--lct-accent-hover);
657
+ box-shadow: var(--lct-shadow-md);
658
+ transform: translateY(-1px);
659
+ }
660
+
661
+ .lct-button:active {
662
+ transform: translateY(0);
663
+ box-shadow: var(--lct-shadow-sm);
664
+ }
494
665
 
495
666
  .lct-button:focus-visible,
496
667
  .lct-chip-remove:focus-visible,
497
668
  .lct-clear-link:focus-visible,
498
669
  .lct-nav a:focus-visible {
499
- border-radius: 6px;
500
670
  box-shadow: var(--lct-focus-ring);
501
671
  outline: none;
502
672
  }
@@ -508,7 +678,15 @@
508
678
  }
509
679
  .lct-button-secondary:hover {
510
680
  background: var(--lct-row-hover);
511
- border-color: var(--lct-border);
681
+ border-color: var(--lct-border-strong);
682
+ }
683
+ .lct-button-secondary:active {
684
+ background: var(--lct-surface);
685
+ }
686
+
687
+ @media (prefers-reduced-motion: reduce) {
688
+ .lct-button { transition: none; }
689
+ .lct-button:hover { transform: none; }
512
690
  }
513
691
 
514
692
  .lct-button-compact {
@@ -536,7 +714,7 @@
536
714
  }
537
715
 
538
716
  .lct-chip-remove {
539
- color: rgba(10, 37, 64, 0.58);
717
+ color: var(--lct-chip-remove);
540
718
  font-size: var(--fs-lg);
541
719
  line-height: 1;
542
720
  text-decoration: none;
@@ -586,14 +764,15 @@
586
764
 
587
765
  .lct-pagination-info strong { color: var(--lct-text); font-weight: 600; }
588
766
 
589
- .lct-pagination-per { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
590
- .lct-pagination-per-label { color: var(--lct-muted); font-size: var(--fs-sm); font-weight: 500; }
767
+ .lct-pagination-per { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; }
768
+ .lct-pagination-per-label { color: var(--lct-muted); font-size: var(--fs-xs); font-weight: 500; }
591
769
 
592
770
  .lct-pagination-per-option {
593
771
  color: var(--lct-muted);
594
772
  text-decoration: none;
595
773
  padding: 2px 8px;
596
774
  border-radius: 999px;
775
+ font-size: var(--fs-xs);
597
776
  font-variant-numeric: tabular-nums;
598
777
  }
599
778
 
@@ -651,7 +830,6 @@
651
830
 
652
831
  .lct-pagination-nav > *:last-child { border-right: 0; }
653
832
 
654
- .lct-tags { display: inline-block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
655
833
  .lct-nowrap { white-space: nowrap; }
656
834
 
657
835
  .lct-detail-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@@ -663,7 +841,7 @@
663
841
  margin: 0;
664
842
  }
665
843
 
666
- .lct-dl dd { margin: 0; }
844
+ .lct-dl dd { margin: 0; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
667
845
 
668
846
  .lct-pre {
669
847
  background: #0f172a;
@@ -740,8 +918,7 @@
740
918
  .lct-filters { gap: 12px; }
741
919
 
742
920
  .lct-filter-row,
743
- .lct-filter-row-basic,
744
- .lct-filter-row-with-sort { align-items: stretch; grid-template-columns: 1fr; }
921
+ .lct-filter-row-basic { align-items: stretch; grid-template-columns: 1fr; }
745
922
 
746
923
  .lct-filter-actions { justify-content: flex-start; width: 100%; }
747
924
 
@@ -11,30 +11,43 @@ module LlmCostTracker
11
11
  rescue_from ActiveRecord::StatementInvalid, with: :render_database_error
12
12
  rescue_from LlmCostTracker::InvalidFilterError, with: :render_invalid_filter
13
13
 
14
+ SCHEMA_CHECKS = [
15
+ [
16
+ LlmCostTracker::Ledger::Schema::Calls,
17
+ "The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
18
+ ],
19
+ [
20
+ LlmCostTracker::Ledger::Schema::CallRollups,
21
+ "The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
22
+ ],
23
+ [
24
+ LlmCostTracker::Ledger::Schema::CallLineItems,
25
+ "The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
26
+ ],
27
+ [
28
+ LlmCostTracker::Ledger::Schema::CallTags,
29
+ "The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
30
+ ]
31
+ ].freeze
32
+
33
+ private_constant :SCHEMA_CHECKS
34
+
14
35
  private
15
36
 
16
37
  def ensure_current_schema
17
- unless LlmCostTracker::Ledger::Call.table_exists?
18
- @setup_message = "The llm_api_calls table is not available yet."
38
+ unless LlmCostTracker::Call.table_exists?
39
+ @setup_message = "The llm_cost_tracker_calls table is not available yet."
19
40
  return render template: "llm_cost_tracker/shared/setup_required"
20
41
  end
21
42
 
22
- schema_errors = LlmCostTracker::Ledger::Schema::Calls.current_schema_errors
23
- if schema_errors.any?
24
- @setup_message = "The llm_api_calls table does not match the current LLM Cost Tracker schema."
25
- @setup_details = schema_errors
26
- render template: "llm_cost_tracker/shared/setup_required"
27
- return
28
- end
29
-
30
- period_total_errors = LlmCostTracker::Ledger::Schema::PeriodTotals.current_schema_errors
31
- return if period_total_errors.empty?
43
+ SCHEMA_CHECKS.each do |schema, message|
44
+ errors = schema.current_schema_errors
45
+ next if errors.empty?
32
46
 
33
- @setup_message = "The llm_cost_tracker_period_totals table does not match the current LLM Cost Tracker schema."
34
- @setup_details = period_total_errors + [
35
- "run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
36
- ]
37
- render template: "llm_cost_tracker/shared/setup_required"
47
+ @setup_message = message
48
+ @setup_details = errors + ["See docs/upgrading.md for the migration path."]
49
+ return render template: "llm_cost_tracker/shared/setup_required"
50
+ end
38
51
  end
39
52
 
40
53
  def render_database_error(error)
@@ -3,8 +3,18 @@
3
3
  module LlmCostTracker
4
4
  class AssetsController < ActionController::Base
5
5
  def stylesheet
6
- response.set_header("Cache-Control", "public, max-age=31536000, immutable")
6
+ response.set_header("Cache-Control", cache_control_header)
7
7
  send_file LlmCostTracker::Assets::STYLESHEET_PATH, type: "text/css", disposition: "inline"
8
8
  end
9
+
10
+ private
11
+
12
+ def cache_control_header
13
+ if Rails.env.development?
14
+ "no-store"
15
+ else
16
+ "public, max-age=31536000, immutable"
17
+ end
18
+ end
9
19
  end
10
20
  end
@@ -19,7 +19,7 @@ module LlmCostTracker
19
19
  format.html do
20
20
  @page = Dashboard::Pagination.call(params)
21
21
  @calls_count = scope.count
22
- @calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
22
+ @calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
23
23
  end
24
24
  format.csv do
25
25
  send_data render_csv(ordered_scope.limit(CSV_EXPORT_LIMIT)),
@@ -30,7 +30,7 @@ module LlmCostTracker
30
30
  end
31
31
 
32
32
  def show
33
- @call = Ledger::Call.find(params[:id])
33
+ @call = LlmCostTracker::Call.find(params[:id])
34
34
  end
35
35
 
36
36
  private
@@ -55,33 +55,38 @@ module LlmCostTracker
55
55
  CSV.generate do |csv|
56
56
  csv << fields.map(&:to_s)
57
57
 
58
- relation.pluck(*fields).each do |values|
59
- csv << fields.zip(values).map { |field, value| csv_value(field, value) }
58
+ relation.includes(:tag_records).each do |call|
59
+ csv << fields.map { |field| csv_value(field, call) }
60
60
  end
61
61
  end
62
62
  end
63
63
 
64
64
  def csv_fields
65
65
  %i[tracked_at provider model] +
66
- TokenUsage::STORED_KEYS +
67
- Pricing::COST_KEYS +
68
- %i[latency_ms provider_response_id tags]
66
+ TokenUsage.members +
67
+ %i[
68
+ total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
69
+ provider_api_key_id provider_workspace_id batch tags
70
+ ]
69
71
  end
70
72
 
71
- def csv_value(field, value)
73
+ def csv_value(field, call)
72
74
  case field
73
75
  when :tracked_at
74
- value&.utc&.iso8601
75
- when :provider, :model, :provider_response_id
76
- csv_safe(value)
76
+ call.tracked_at&.utc&.iso8601
77
+ when :provider, :model, :provider_response_id, :provider_project_id, :provider_api_key_id,
78
+ :provider_workspace_id, :cost_status
79
+ csv_safe(call[field])
80
+ when :pricing_snapshot
81
+ csv_safe(csv_json(call.pricing_snapshot))
77
82
  when :tags
78
- csv_safe(csv_tags(value))
83
+ csv_safe(call.parsed_tags.to_json)
79
84
  else
80
- value
85
+ call[field]
81
86
  end
82
87
  end
83
88
 
84
- def csv_tags(value)
89
+ def csv_json(value)
85
90
  return value.transform_keys(&:to_s).to_json if value.is_a?(Hash)
86
91
 
87
92
  JSON.parse(value || "{}").to_json
@@ -5,9 +5,17 @@ module LlmCostTracker
5
5
  def index
6
6
  scope = Dashboard::Filter.call(params: params)
7
7
  @stats = Dashboard::DataQuality.call(scope: scope)
8
- @usage_rows = Dashboard::DataQuality.usage_rows(@stats)
8
+ @summary = Dashboard::DataQuality.summary(@stats)
9
+ @usage_rows = Dashboard::DataQuality.usage_rows(
10
+ @stats,
11
+ component_costs: Dashboard::DataQuality.component_costs(scope)
12
+ )
9
13
  @hidden_output_summary = Dashboard::DataQuality.hidden_output_summary(@stats)
10
- @unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(scope)
14
+ @unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(
15
+ scope,
16
+ total_calls: @summary.total
17
+ )
18
+ @service_charge_rows = Dashboard::DataQuality.service_charge_rows(scope).to_a
11
19
  end
12
20
  end
13
21
  end