llm_cost_tracker 0.7.3 → 0.9.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. 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,34 @@
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
+ .lct-grid > .lct-panel + .lct-panel,
167
+ .lct-grid > .lct-stat-grid + .lct-panel { margin-top: 0; }
168
+
169
+ .lct-breadcrumb {
170
+ align-items: center;
171
+ color: var(--lct-muted);
172
+ display: flex;
173
+ font-size: var(--fs-sm);
174
+ gap: 8px;
175
+ margin-bottom: 14px;
176
+ }
177
+ .lct-breadcrumb-link {
178
+ color: var(--lct-muted);
179
+ text-decoration: none;
180
+ transition: color 0.15s ease;
181
+ }
182
+ .lct-breadcrumb-link:hover { color: var(--lct-text); }
183
+ .lct-breadcrumb-link:focus-visible {
184
+ color: var(--lct-text);
185
+ outline: 2px solid var(--lct-accent-soft);
186
+ outline-offset: 2px;
187
+ border-radius: 2px;
188
+ }
189
+ .lct-breadcrumb-sep { color: var(--lct-border-strong); }
190
+ .lct-breadcrumb-current { color: var(--lct-text); font-weight: 500; }
191
+
96
192
  .lct-banner {
97
193
  align-items: center;
98
194
  border-radius: 8px;
@@ -108,13 +204,13 @@
108
204
  .lct-banner-title { font-size: var(--fs-md); font-weight: 600; margin: 0; }
109
205
  .lct-banner-muted { color: var(--lct-muted); font-weight: 400; }
110
206
 
111
- .lct-banner-warning { background: var(--lct-warning-soft); border-color: var(--lct-warning-border); color: #7c2d12; }
207
+ .lct-banner-warning { background: var(--lct-warning-soft); border-color: var(--lct-warning-border); color: var(--lct-warning-strong); }
112
208
  .lct-banner-warning .lct-banner-muted,
113
- .lct-banner-warning .lct-banner-copy { color: #9a3412; }
209
+ .lct-banner-warning .lct-banner-copy { color: var(--lct-warning-copy); }
114
210
 
115
- .lct-banner-danger { background: var(--lct-danger-soft); border-color: var(--lct-danger-border); color: #991b1b; }
211
+ .lct-banner-danger { background: var(--lct-danger-soft); border-color: var(--lct-danger-border); color: var(--lct-danger-strong); }
116
212
  .lct-banner-danger .lct-banner-muted,
117
- .lct-banner-danger .lct-banner-copy { color: #b91c1c; }
213
+ .lct-banner-danger .lct-banner-copy { color: var(--lct-danger-copy); }
118
214
 
119
215
  .lct-muted { color: var(--lct-muted); }
120
216
 
@@ -158,7 +254,7 @@
158
254
 
159
255
  .lct-hero-side .lct-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
160
256
  .lct-stat-grid-spaced { margin-bottom: 16px; }
161
- .lct-two-col { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
257
+ .lct-two-col { align-items: start; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
162
258
 
163
259
  .lct-stat {
164
260
  align-items: flex-start;
@@ -185,7 +281,6 @@
185
281
 
186
282
  .lct-stat-label,
187
283
  .lct-field label,
188
- .lct-dl dt,
189
284
  .lct-call-summary-label,
190
285
  .lct-call-breakdown-title,
191
286
  .lct-chip-label {
@@ -230,6 +325,21 @@
230
325
  .lct-table-compact th,
231
326
  .lct-table-compact td { padding: 10px 12px; }
232
327
 
328
+ .lct-badge {
329
+ align-items: center;
330
+ border-radius: 999px;
331
+ display: inline-flex;
332
+ font-size: var(--fs-xs);
333
+ font-weight: 600;
334
+ letter-spacing: 0.02em;
335
+ line-height: 1;
336
+ padding: 4px 10px;
337
+ text-transform: uppercase;
338
+ vertical-align: middle;
339
+ }
340
+ .lct-badge-ok { background: var(--lct-success-soft); color: var(--lct-success); }
341
+ .lct-badge-warn { background: var(--lct-warning-soft); color: var(--lct-warning-strong); }
342
+
233
343
  .lct-delta-badge {
234
344
  align-items: center;
235
345
  border-radius: 999px;
@@ -268,16 +378,26 @@
268
378
  .lct-table tbody tr:last-child td { border-bottom: 0; }
269
379
 
270
380
  .lct-table th {
271
- background: #fbfcfe;
381
+ background: var(--lct-th-bg);
272
382
  color: var(--lct-muted);
273
383
  font-weight: 600;
274
384
  font-size: var(--fs-xs);
275
385
  letter-spacing: 0;
276
386
  text-transform: none;
387
+ position: sticky;
388
+ top: 0;
389
+ z-index: 1;
277
390
  }
278
391
 
392
+ .lct-table tbody tr {
393
+ transition: background-color 0.1s ease;
394
+ }
279
395
  .lct-table tbody tr:hover { background: var(--lct-row-hover); }
280
396
 
397
+ @media (prefers-reduced-motion: reduce) {
398
+ .lct-table tbody tr { transition: none; }
399
+ }
400
+
281
401
  .lct-table td:last-child,
282
402
  .lct-table th:last-child,
283
403
  .lct-calls-table td:last-child,
@@ -304,8 +424,12 @@
304
424
  .lct-stack-fill-input { background: var(--lct-accent); }
305
425
  .lct-stack-fill-cache-read { background: #22c55e; }
306
426
  .lct-stack-fill-cache-write { background: #f59e0b; }
307
- .lct-stack-fill-cache-write-1h { background: #a855f7; }
427
+ .lct-stack-fill-cache-write-extended { background: #a855f7; }
428
+ .lct-stack-fill-audio-input { background: #ec4899; }
429
+ .lct-stack-fill-image-input { background: #f97316; }
308
430
  .lct-stack-fill-output { background: #0ea5e9; }
431
+ .lct-stack-fill-audio-output { background: #14b8a6; }
432
+ .lct-stack-fill-image-output { background: #84cc16; }
309
433
 
310
434
  .lct-budget { display: grid; gap: 10px; }
311
435
  .lct-budget-head { align-items: baseline; display: flex; gap: 10px; justify-content: space-between; }
@@ -318,7 +442,7 @@
318
442
  .lct-budget-of { color: var(--lct-muted); font-size: var(--fs-sm); font-weight: 500; }
319
443
  .lct-budget-percent { font-size: var(--fs-md); font-variant-numeric: tabular-nums; font-weight: 700; }
320
444
  .lct-budget-marker {
321
- border-left: 2px dashed rgba(10, 37, 64, 0.45);
445
+ border-left: 2px dashed var(--lct-chart-marker);
322
446
  bottom: 0;
323
447
  position: absolute;
324
448
  top: 0;
@@ -335,7 +459,7 @@
335
459
  }
336
460
  .lct-budget-projection strong { color: var(--lct-text); }
337
461
  .lct-budget-projection-status { font-weight: 600; }
338
- .lct-budget-projection-status--over { color: #9a3412; }
462
+ .lct-budget-projection-status--over { color: var(--lct-warning-copy); }
339
463
  .lct-budget-projection-status--under { color: var(--lct-muted); }
340
464
  .lct-budget-meta { color: var(--lct-muted); font-size: var(--fs-xs); }
341
465
 
@@ -344,7 +468,7 @@
344
468
  .lct-chart-line-secondary {
345
469
  fill: none;
346
470
  opacity: 0.75;
347
- stroke: rgba(10, 37, 64, 0.42);
471
+ stroke: var(--lct-chart-secondary);
348
472
  stroke-dasharray: 5 4;
349
473
  stroke-linejoin: round;
350
474
  stroke-linecap: round;
@@ -365,7 +489,7 @@
365
489
  .lct-chart-legend-compare { align-items: center; display: inline-flex; gap: 12px; }
366
490
  .lct-chart-key { align-items: center; display: inline-flex; gap: 6px; }
367
491
  .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; }
492
+ .lct-chart-key-line-secondary { background: var(--lct-chart-secondary); position: relative; }
369
493
  .lct-chart-key-line-secondary::after {
370
494
  background: linear-gradient(90deg, transparent 40%, var(--lct-bg) 40%, var(--lct-bg) 60%, transparent 60%);
371
495
  content: "";
@@ -399,15 +523,35 @@
399
523
  .lct-tag-chip-more { background: transparent; border: 1px dashed var(--lct-border); color: var(--lct-muted); }
400
524
  .lct-tag-empty { color: var(--lct-muted); font-size: var(--fs-sm); font-style: italic; }
401
525
 
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; }
526
+ .lct-empty {
527
+ padding: 56px 28px;
528
+ text-align: center;
529
+ background:
530
+ radial-gradient(ellipse 60% 50% at 50% 0%, rgba(99, 91, 255, 0.04), transparent 70%),
531
+ var(--lct-panel);
532
+ }
533
+ .lct-state-title { font-size: var(--fs-lg); font-weight: 600; margin: 0 0 6px; letter-spacing: -0.005em; }
534
+ .lct-state-copy { color: var(--lct-muted); margin: 0 auto; max-width: 480px; font-size: var(--fs-sm); line-height: 1.55; }
535
+ .lct-state-pre {
536
+ background: var(--lct-bg);
537
+ border: 1px solid var(--lct-border);
538
+ border-radius: 6px;
539
+ color: var(--lct-text);
540
+ font-family: var(--mono);
541
+ font-size: var(--fs-sm);
542
+ line-height: 1.55;
543
+ margin: 12px auto 0;
544
+ max-width: 560px;
545
+ overflow: auto;
546
+ padding: 12px 14px;
547
+ text-align: left;
548
+ }
549
+ .lct-state-actions { display: flex; gap: 8px; justify-content: center; margin-top: 20px; }
406
550
 
407
551
  .lct-toolbar {
408
- background: rgba(255, 255, 255, 0.72);
552
+ background: var(--lct-toolbar-bg);
409
553
  backdrop-filter: blur(10px);
410
- border-color: #e7ecf3;
554
+ border-color: var(--lct-toolbar-border);
411
555
  box-shadow: none;
412
556
  padding: 16px;
413
557
  margin-bottom: 16px;
@@ -429,52 +573,124 @@
429
573
  .lct-toolbar-actions .lct-button,
430
574
  .lct-banner .lct-button { font-size: var(--fs-sm); height: 32px; padding: 0 12px; }
431
575
 
432
- .lct-filters { display: grid; gap: 8px; }
576
+ .lct-filters { display: block; }
433
577
 
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; }
578
+ .lct-filter-row {
579
+ align-items: flex-end;
580
+ display: flex;
581
+ flex-wrap: wrap;
582
+ gap: 12px;
583
+ }
437
584
 
438
585
  .lct-filter-actions {
439
- align-items: end;
586
+ align-items: flex-end;
440
587
  display: flex;
441
588
  flex-wrap: wrap;
442
589
  gap: 8px;
443
- justify-content: flex-start;
590
+ margin-left: auto;
444
591
  }
445
592
 
446
- .lct-field { display: inline-block; }
593
+ .lct-field {
594
+ display: flex;
595
+ flex: 1 1 160px;
596
+ flex-direction: column;
597
+ gap: 6px;
598
+ min-width: 140px;
599
+ }
447
600
 
448
601
  .lct-field input,
449
602
  .lct-field select {
450
- border: 0;
603
+ appearance: none;
604
+ -webkit-appearance: none;
605
+ -moz-appearance: none;
606
+ background: var(--lct-panel);
607
+ border: 1px solid var(--lct-border);
451
608
  border-radius: 8px;
609
+ box-sizing: border-box;
452
610
  color: var(--lct-text);
453
611
  font-family: inherit;
454
612
  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
613
  height: var(--lct-control-height);
459
614
  line-height: 1.2;
460
615
  padding: 0 12px;
461
616
  width: 100%;
462
617
  }
463
618
 
464
- .lct-field input::placeholder { color: #8792a2; }
619
+ .lct-field select {
620
+ 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>");
621
+ background-position: right 10px center;
622
+ background-repeat: no-repeat;
623
+ background-size: 12px 12px;
624
+ padding-right: 32px;
625
+ }
626
+
627
+ .lct-field input[type="date"] {
628
+ font-variant-numeric: tabular-nums;
629
+ padding-right: 8px;
630
+ }
631
+
632
+ .lct-field input[type="date"]::-webkit-calendar-picker-indicator {
633
+ cursor: pointer;
634
+ filter: invert(45%) sepia(8%) saturate(437%) hue-rotate(176deg) brightness(95%) contrast(86%);
635
+ opacity: 0.85;
636
+ }
637
+
638
+ .lct-field input::placeholder { color: var(--lct-muted); }
639
+
640
+ .lct-field input:hover,
641
+ .lct-field select:hover { border-color: var(--lct-border-strong); }
465
642
 
466
643
  .lct-field input:focus,
467
644
  .lct-field select:focus {
468
- box-shadow: inset 0 0 0 2px var(--lct-accent);
645
+ border-color: var(--lct-accent);
646
+ box-shadow: var(--lct-focus-ring);
469
647
  outline: none;
470
648
  }
471
649
 
650
+ .lct-results-toolbar {
651
+ align-items: center;
652
+ display: flex;
653
+ flex-wrap: wrap;
654
+ gap: 12px;
655
+ margin: -4px 0 8px;
656
+ }
657
+
658
+ .lct-results-toolbar .lct-pagination-per { order: 0; margin-right: auto; }
659
+ .lct-results-toolbar .lct-sort { order: 1; margin-left: auto; }
660
+
661
+ .lct-sort {
662
+ align-items: center;
663
+ background: var(--lct-surface);
664
+ border-radius: 6px;
665
+ display: inline-flex;
666
+ flex-wrap: wrap;
667
+ gap: 2px;
668
+ padding: 2px;
669
+ }
670
+
671
+ .lct-sort-option {
672
+ border-radius: 4px;
673
+ color: var(--lct-muted);
674
+ display: inline-block;
675
+ font-size: var(--fs-xs);
676
+ font-weight: 500;
677
+ padding: 4px 8px;
678
+ text-decoration: none;
679
+ }
680
+
681
+ .lct-sort-option:hover { background: var(--lct-panel); color: var(--lct-text); }
682
+ .lct-sort-option.is-active {
683
+ background: var(--lct-panel);
684
+ box-shadow: var(--lct-shadow);
685
+ color: var(--lct-text);
686
+ }
687
+
472
688
  .lct-button {
473
689
  align-items: center;
474
690
  background: var(--lct-accent);
475
691
  border: 1px solid var(--lct-accent);
476
692
  border-radius: 8px;
477
- box-shadow: 0 1px 2px rgba(10, 37, 64, 0.08);
693
+ box-shadow: var(--lct-shadow-sm);
478
694
  color: #ffffff;
479
695
  cursor: pointer;
480
696
  display: inline-flex;
@@ -486,17 +702,26 @@
486
702
  height: var(--lct-control-height);
487
703
  padding: 0 14px;
488
704
  text-decoration: none;
489
- transition: background-color 0.12s ease, border-color 0.12s ease;
705
+ transition: background-color 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease, transform 0.08s ease;
490
706
  white-space: nowrap;
491
707
  }
492
708
 
493
- .lct-button:hover { background: var(--lct-accent-hover); border-color: var(--lct-accent-hover); }
709
+ .lct-button:hover {
710
+ background: var(--lct-accent-hover);
711
+ border-color: var(--lct-accent-hover);
712
+ box-shadow: var(--lct-shadow-md);
713
+ transform: translateY(-1px);
714
+ }
715
+
716
+ .lct-button:active {
717
+ transform: translateY(0);
718
+ box-shadow: var(--lct-shadow-sm);
719
+ }
494
720
 
495
721
  .lct-button:focus-visible,
496
722
  .lct-chip-remove:focus-visible,
497
723
  .lct-clear-link:focus-visible,
498
724
  .lct-nav a:focus-visible {
499
- border-radius: 6px;
500
725
  box-shadow: var(--lct-focus-ring);
501
726
  outline: none;
502
727
  }
@@ -508,7 +733,15 @@
508
733
  }
509
734
  .lct-button-secondary:hover {
510
735
  background: var(--lct-row-hover);
511
- border-color: var(--lct-border);
736
+ border-color: var(--lct-border-strong);
737
+ }
738
+ .lct-button-secondary:active {
739
+ background: var(--lct-surface);
740
+ }
741
+
742
+ @media (prefers-reduced-motion: reduce) {
743
+ .lct-button { transition: none; }
744
+ .lct-button:hover { transform: none; }
512
745
  }
513
746
 
514
747
  .lct-button-compact {
@@ -536,7 +769,7 @@
536
769
  }
537
770
 
538
771
  .lct-chip-remove {
539
- color: rgba(10, 37, 64, 0.58);
772
+ color: var(--lct-chip-remove);
540
773
  font-size: var(--fs-lg);
541
774
  line-height: 1;
542
775
  text-decoration: none;
@@ -586,14 +819,15 @@
586
819
 
587
820
  .lct-pagination-info strong { color: var(--lct-text); font-weight: 600; }
588
821
 
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; }
822
+ .lct-pagination-per { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; }
823
+ .lct-pagination-per-label { color: var(--lct-muted); font-size: var(--fs-xs); font-weight: 500; }
591
824
 
592
825
  .lct-pagination-per-option {
593
826
  color: var(--lct-muted);
594
827
  text-decoration: none;
595
828
  padding: 2px 8px;
596
829
  border-radius: 999px;
830
+ font-size: var(--fs-xs);
597
831
  font-variant-numeric: tabular-nums;
598
832
  }
599
833
 
@@ -651,19 +885,23 @@
651
885
 
652
886
  .lct-pagination-nav > *:last-child { border-right: 0; }
653
887
 
654
- .lct-tags { display: inline-block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
655
888
  .lct-nowrap { white-space: nowrap; }
656
889
 
657
- .lct-detail-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
890
+ .lct-detail-grid { align-items: start; display: grid; gap: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
658
891
 
659
892
  .lct-dl {
660
893
  display: grid;
661
894
  gap: 12px;
662
- grid-template-columns: minmax(120px, 0.4fr) minmax(0, 1fr);
895
+ grid-template-columns: minmax(120px, 0.45fr) minmax(0, 1fr);
663
896
  margin: 0;
664
897
  }
665
898
 
666
- .lct-dl dd { margin: 0; }
899
+ .lct-dl dt {
900
+ color: var(--lct-muted);
901
+ font-size: var(--fs-sm);
902
+ font-weight: 500;
903
+ }
904
+ .lct-dl dd { color: var(--lct-text); font-size: var(--fs-sm); margin: 0; min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
667
905
 
668
906
  .lct-pre {
669
907
  background: #0f172a;
@@ -740,8 +978,7 @@
740
978
  .lct-filters { gap: 12px; }
741
979
 
742
980
  .lct-filter-row,
743
- .lct-filter-row-basic,
744
- .lct-filter-row-with-sort { align-items: stretch; grid-template-columns: 1fr; }
981
+ .lct-filter-row-basic { align-items: stretch; grid-template-columns: 1fr; }
745
982
 
746
983
  .lct-filter-actions { justify-content: flex-start; width: 100%; }
747
984
 
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module LlmCostTracker
4
6
  class ApplicationController < ActionController::Base
5
7
  layout "llm_cost_tracker/application"
6
8
 
9
+ protect_from_forgery with: :exception
10
+
11
+ before_action :set_dashboard_security_headers
7
12
  before_action :ensure_current_schema
8
13
 
14
+ helper_method :dashboard_csp_nonce
15
+
9
16
  rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
17
+ rescue_from ActiveRecord::AdapterNotSpecified, with: :render_database_error
10
18
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
11
19
  rescue_from ActiveRecord::StatementInvalid, with: :render_database_error
12
20
  rescue_from LlmCostTracker::InvalidFilterError, with: :render_invalid_filter
@@ -14,26 +22,11 @@ module LlmCostTracker
14
22
  private
15
23
 
16
24
  def ensure_current_schema
17
- unless LlmCostTracker::Ledger::Call.table_exists?
18
- @setup_message = "The llm_api_calls table is not available yet."
19
- return render template: "llm_cost_tracker/shared/setup_required"
20
- end
21
-
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?
32
-
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
- ]
25
+ drift = LlmCostTracker::DashboardSetupState.current
26
+ return unless drift
27
+
28
+ @setup_message = drift.message
29
+ @setup_details = drift.details
37
30
  render template: "llm_cost_tracker/shared/setup_required"
38
31
  end
39
32
 
@@ -50,5 +43,17 @@ module LlmCostTracker
50
43
  def render_not_found
51
44
  render "llm_cost_tracker/errors/not_found", status: :not_found
52
45
  end
46
+
47
+ def set_dashboard_security_headers
48
+ nonce = dashboard_csp_nonce
49
+ response.headers["X-Frame-Options"] = "DENY"
50
+ response.headers["Referrer-Policy"] = "same-origin"
51
+ response.headers["Content-Security-Policy"] =
52
+ "default-src 'self'; style-src 'self' 'nonce-#{nonce}'; img-src 'self' data:; frame-ancestors 'none'"
53
+ end
54
+
55
+ def dashboard_csp_nonce
56
+ request.env["llm_cost_tracker.csp_nonce"] ||= SecureRandom.base64(16)
57
+ end
53
58
  end
54
59
  end
@@ -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.headers["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