llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,33 +1,30 @@
1
- <section class="lct-panel">
2
- <div class="lct-toolbar-head">
3
- <h2 class="lct-section-title">Provider Invoice Reconciliation <span class="lct-badge lct-badge-warn">Experimental</span></h2>
4
- <% if @last_imported_at %>
5
- <p class="lct-state-copy">Last import: <%= @last_imported_at.utc.iso8601 %></p>
6
- <% end %>
7
- </div>
8
- <p class="lct-state-copy">
9
- Experimental in v0.9.0 — public API may change in v0.9.x based on feedback.
10
- <%= link_to "Open an issue", "https://github.com/sergey-homenko/llm_cost_tracker/issues", target: "_blank", rel: "noopener" %>
11
- if you use it.
12
- </p>
13
- </section>
1
+ <h2 class="lct-page-title">Provider invoice reconciliation <span class="lct-page-title-meta">Experimental</span></h2>
2
+ <p class="lct-page-subtitle">
3
+ <% if @last_imported_at %>
4
+ <span>Last import: <%= @last_imported_at.utc.iso8601 %></span>
5
+ <span class="lct-page-subtitle-sep">·</span>
6
+ <% end %>
7
+ <span>Public API may change based on feedback — <%= link_to "open an issue", "https://github.com/sergey-homenko/llm_cost_tracker/issues", target: "_blank", rel: "noopener" %> if you use it.</span>
8
+ </p>
14
9
 
15
10
  <% if flash[:notice] %>
16
- <section class="lct-panel"><p class="lct-state-copy"><%= flash[:notice] %></p></section>
11
+ <div class="lct-alert lct-alert-info"><span><%= flash[:notice] %></span></div>
17
12
  <% end %>
18
13
  <% if flash[:alert] %>
19
- <section class="lct-panel"><p class="lct-state-copy"><%= flash[:alert] %></p></section>
14
+ <div class="lct-alert lct-alert-danger"><span><%= flash[:alert] %></span></div>
20
15
  <% end %>
21
16
 
22
17
  <% if @configured_importers.any? %>
23
18
  <section class="lct-panel">
24
- <h3 class="lct-section-title">Trigger import</h3>
25
- <% @configured_importers.each_key do |source| %>
26
- <%= button_to "Re-import #{source}",
27
- reconciliation_import_path(source: source),
28
- method: :post,
29
- class: "lct-button lct-button-secondary" %>
30
- <% end %>
19
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Trigger import</h2></div>
20
+ <div class="lct-panel-body">
21
+ <% @configured_importers.each_key do |source| %>
22
+ <%= button_to "Re-import #{source}",
23
+ reconciliation_import_path(source: source),
24
+ method: :post,
25
+ class: "lct-button lct-button-secondary" %>
26
+ <% end %>
27
+ </div>
31
28
  </section>
32
29
  <% end %>
33
30
 
@@ -35,36 +32,30 @@
35
32
  <section class="lct-panel lct-empty">
36
33
  <h2 class="lct-state-title">Reconciliation disabled</h2>
37
34
  <p class="lct-state-copy">
38
- Provider invoice reconciliation is opt-in because it requires admin/org-level
39
- provider API keys (OpenAI <code>sk-admin-…</code>, Anthropic admin keys, GCP
40
- <code>billing.viewer</code>) — separate from the runtime inference key the
41
- tracker uses. Enable explicitly in the initializer:
35
+ Provider invoice reconciliation is opt-in because it requires admin/org-level provider API keys (OpenAI <code class="lct-code-id">sk-admin-…</code>, Anthropic admin keys, GCP <code class="lct-code-id">billing.viewer</code>) — separate from the runtime inference key the tracker uses. Enable explicitly in the initializer:
42
36
  </p>
43
- <pre class="lct-state-pre"><code>LlmCostTracker.configure do |config|
37
+ <pre class="lct-pre">LlmCostTracker.configure do |config|
44
38
  config.reconciliation_enabled = true
45
- end</code></pre>
39
+ end</pre>
46
40
  </section>
47
41
  <% elsif !@reconciliation_installed %>
48
42
  <section class="lct-panel lct-empty">
49
43
  <h2 class="lct-state-title">Reconciliation not installed</h2>
50
- <p class="lct-state-copy">
51
- Run the optional migration to create the reconciliation tables:
52
- </p>
53
- <pre class="lct-state-pre"><code>bin/rails generate llm_cost_tracker:reconciliation
54
- bin/rails db:migrate</code></pre>
44
+ <p class="lct-state-copy">Run the optional migration to create the reconciliation tables:</p>
45
+ <pre class="lct-pre">bin/rails generate llm_cost_tracker:reconciliation
46
+ bin/rails db:migrate</pre>
55
47
  </section>
56
48
  <% elsif @diffs.empty? %>
57
49
  <section class="lct-panel lct-empty">
58
50
  <h2 class="lct-state-title">No invoices imported yet</h2>
59
51
  <p class="lct-state-copy">
60
- Reconciliation compares provider-side invoices against local cost. Once you import
61
- invoice rows via <code>LlmCostTracker::Reconciliation.import</code>, they appear here.
52
+ Reconciliation compares provider-side invoices against local cost. Once you import invoice rows via <code class="lct-code-id">LlmCostTracker::Reconciliation.import</code>, they appear here.
62
53
  </p>
63
54
  </section>
64
55
  <% else %>
65
56
  <section class="lct-panel">
66
- <h3 class="lct-section-title">Latest period per source / provider / currency</h3>
67
- <table class="lct-table">
57
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Latest period per source / provider / currency</h2></div>
58
+ <table class="lct-tbl">
68
59
  <thead>
69
60
  <tr>
70
61
  <th>Source</th>
@@ -81,8 +72,8 @@ bin/rails db:migrate</code></pre>
81
72
  <tbody>
82
73
  <% @diffs.each do |diff| %>
83
74
  <tr>
84
- <td><%= diff.source %></td>
85
- <td><%= diff.provider %></td>
75
+ <td><code class="lct-code-id"><%= diff.source %></code></td>
76
+ <td><span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= diff.provider %>"></span><%= diff.provider %></span></td>
86
77
  <td><%= diff.currency %></td>
87
78
  <td><%= diff.period_start %> → <%= diff.period_end %></td>
88
79
  <td class="lct-num"><%= money(diff.provider_total) %></td>
@@ -91,9 +82,9 @@ bin/rails db:migrate</code></pre>
91
82
  <td class="lct-num"><%= diff.delta_percent.nil? ? "—" : "#{diff.delta_percent}%" %></td>
92
83
  <td>
93
84
  <% if diff.aligned?(threshold_percent: @threshold) %>
94
- <span class="lct-badge lct-badge-ok">Aligned</span>
85
+ <span class="lct-status-pill lct-status-pill-ok">Aligned</span>
95
86
  <% else %>
96
- <span class="lct-badge lct-badge-warn">Drift</span>
87
+ <span class="lct-status-pill lct-status-pill-warn">Drift</span>
97
88
  <% end %>
98
89
  </td>
99
90
  </tr>
@@ -106,23 +97,23 @@ bin/rails db:migrate</code></pre>
106
97
  <% next if diff.unmatched_provider_rows.empty? && diff.unmatched_local_calls.empty? && diff.non_cost_rows.empty? %>
107
98
 
108
99
  <section class="lct-panel">
109
- <h3 class="lct-section-title"><%= diff.source %> / <%= diff.provider %> / <%= diff.currency %> — drill down</h3>
100
+ <div class="lct-panel-head"><h2 class="lct-panel-title"><code class="lct-code-id"><%= diff.source %></code> / <%= diff.provider %> / <%= diff.currency %> — drill down</h2></div>
110
101
 
111
102
  <% if diff.unmatched_provider_rows.any? %>
112
- <h4 class="lct-state-title">
113
- Provider rows without a matching local call
103
+ <p class="lct-panel-intro">
104
+ <strong>Provider rows without a matching local call</strong>
114
105
  <% if diff.unmatched_provider_rows_truncated? %>
115
- <small>(showing <%= diff.unmatched_provider_rows.size %> of <%= diff.unmatched_provider_rows_total %>, ranked by billed amount)</small>
106
+ <span class="lct-num-muted">(showing <%= diff.unmatched_provider_rows.size %> of <%= diff.unmatched_provider_rows_total %>, ranked by billed amount)</span>
116
107
  <% end %>
117
- </h4>
118
- <table class="lct-table">
108
+ </p>
109
+ <table class="lct-tbl">
119
110
  <thead>
120
111
  <tr><th>External ID</th><th>Match basis</th><th>Attribution</th><th class="lct-num">Billed</th></tr>
121
112
  </thead>
122
113
  <tbody>
123
114
  <% diff.unmatched_provider_rows.each do |row| %>
124
115
  <tr>
125
- <td><%= row[:external_id] %></td>
116
+ <td><code class="lct-code-id"><%= row[:external_id] %></code></td>
126
117
  <td><%= row[:match_basis] %></td>
127
118
  <td><%= attribution_summary(row[:attribution]) %></td>
128
119
  <td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
@@ -133,13 +124,13 @@ bin/rails db:migrate</code></pre>
133
124
  <% end %>
134
125
 
135
126
  <% if diff.unmatched_local_calls.any? %>
136
- <h4 class="lct-state-title">
137
- Local calls no provider invoice can explain
127
+ <p class="lct-panel-intro">
128
+ <strong>Local calls no provider invoice can explain</strong>
138
129
  <% if diff.unmatched_local_calls_truncated? %>
139
- <small>(showing <%= diff.unmatched_local_calls.size %> of <%= diff.unmatched_local_calls_total %>, ranked by total cost)</small>
130
+ <span class="lct-num-muted">(showing <%= diff.unmatched_local_calls.size %> of <%= diff.unmatched_local_calls_total %>, ranked by total cost)</span>
140
131
  <% end %>
141
- </h4>
142
- <table class="lct-table">
132
+ </p>
133
+ <table class="lct-tbl">
143
134
  <thead>
144
135
  <tr><th>Attribution</th><th class="lct-num">Calls</th><th class="lct-num">Total cost</th></tr>
145
136
  </thead>
@@ -156,13 +147,13 @@ bin/rails db:migrate</code></pre>
156
147
  <% end %>
157
148
 
158
149
  <% if diff.non_cost_rows.any? %>
159
- <h4 class="lct-state-title">
160
- Non-cost evidence (free quota, credits, adjustments)
150
+ <p class="lct-panel-intro">
151
+ <strong>Non-cost evidence</strong> (free quota, credits, adjustments)
161
152
  <% if diff.non_cost_rows_truncated? %>
162
- <small>(showing <%= diff.non_cost_rows.size %> of <%= diff.non_cost_rows_total %>, ranked by amount)</small>
153
+ <span class="lct-num-muted">(showing <%= diff.non_cost_rows.size %> of <%= diff.non_cost_rows_total %>, ranked by amount)</span>
163
154
  <% end %>
164
- </h4>
165
- <table class="lct-table">
155
+ </p>
156
+ <table class="lct-tbl">
166
157
  <thead>
167
158
  <tr><th>Row type</th><th>Meter</th><th>Attribution</th><th class="lct-num">Amount</th></tr>
168
159
  </thead>
@@ -0,0 +1,19 @@
1
+ <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
+ <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
+ <details class="lct-filter-pop" name="lct-filter">
4
+ <summary class="lct-filter-pill">
5
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
6
+ <span class="lct-filter-pill-key">Date</span>
7
+ <span class="lct-filter-pill-value"><%= @from_date.strftime("%b %-d") %> – <%= @to_date.strftime("%b %-d") %></span>
8
+ <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
9
+ </summary>
10
+ <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
11
+ <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
12
+ <% current_query.except(:from, :to, :page, :per, *extra_except).each do |key, value| %>
13
+ <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
14
+ <% end %>
15
+ <div class="lct-filter-pop-field"><label for="lct-filter-from">From</label><input type="date" name="from" id="lct-filter-from" value="<%= @from_date.iso8601 %>"></div>
16
+ <div class="lct-filter-pop-field"><label for="lct-filter-to">To</label><input type="date" name="to" id="lct-filter-to" value="<%= @to_date.iso8601 %>"></div>
17
+ <button type="submit" class="lct-button lct-button-primary">Apply</button>
18
+ <% end %>
19
+ </details>
@@ -0,0 +1,22 @@
1
+ <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
+ <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
+ <% active = params[:model].presence %>
4
+ <details class="lct-filter-pop" name="lct-filter">
5
+ <summary class="lct-filter-pill <%= 'lct-active' if active %>">
6
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
7
+ <span class="lct-filter-pill-key">Model</span>
8
+ <span class="lct-filter-pill-value"><%= active || "All" %></span>
9
+ <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
10
+ </summary>
11
+ <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
+ <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
+ <% current_query.except(:model, :page, :per, *extra_except).each do |key, value| %>
14
+ <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
15
+ <% end %>
16
+ <div class="lct-filter-pop-field">
17
+ <label for="lct-filter-model">Model</label>
18
+ <%= select_tag :model, options_for_select(model_filter_options, active), include_blank: "All models", id: "lct-filter-model" %>
19
+ </div>
20
+ <button type="submit" class="lct-button lct-button-primary">Apply</button>
21
+ <% end %>
22
+ </details>
@@ -0,0 +1,22 @@
1
+ <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
+ <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
+ <% active = params[:provider].presence %>
4
+ <details class="lct-filter-pop" name="lct-filter">
5
+ <summary class="lct-filter-pill <%= 'lct-active' if active %>">
6
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
7
+ <span class="lct-filter-pill-key">Provider</span>
8
+ <span class="lct-filter-pill-value"><%= active || "All" %></span>
9
+ <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
10
+ </summary>
11
+ <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
12
+ <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
13
+ <% current_query.except(:provider, :page, :per, *extra_except).each do |key, value| %>
14
+ <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
15
+ <% end %>
16
+ <div class="lct-filter-pop-field">
17
+ <label for="lct-filter-provider">Provider</label>
18
+ <%= select_tag :provider, options_for_select(provider_filter_options, active), include_blank: "All providers", id: "lct-filter-provider" %>
19
+ </div>
20
+ <button type="submit" class="lct-button lct-button-primary">Apply</button>
21
+ <% end %>
22
+ </details>
@@ -0,0 +1,23 @@
1
+ <% extra_hidden = local_assigns.fetch(:extra_hidden, {}) %>
2
+ <% extra_except = local_assigns.fetch(:extra_except, []) %>
3
+ <% active = params[:stream].presence %>
4
+ <% display_value = case active when "yes" then "Streaming" when "no" then "Non-streaming" else "All" end %>
5
+ <details class="lct-filter-pop" name="lct-filter">
6
+ <summary class="lct-filter-pill <%= 'lct-active' if active %>">
7
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
8
+ <span class="lct-filter-pill-key">Stream</span>
9
+ <span class="lct-filter-pill-value"><%= display_value %></span>
10
+ <svg class="lct-chev" width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l4 4 4-4"/></svg>
11
+ </summary>
12
+ <%= form_with url: path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
13
+ <% extra_hidden.each do |k, v| %><%= hidden_field_tag k, v %><% end %>
14
+ <% current_query.except(:stream, :page, :per, *extra_except).each do |key, value| %>
15
+ <% Array(value).each do |v| %><%= hidden_field_tag(value.is_a?(Array) ? "#{key}[]" : key.to_s, v) %><% end %>
16
+ <% end %>
17
+ <div class="lct-filter-pop-field">
18
+ <label for="lct-filter-stream">Stream</label>
19
+ <%= select_tag :stream, options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, active), include_blank: "All calls", id: "lct-filter-stream" %>
20
+ </div>
21
+ <button type="submit" class="lct-button lct-button-primary">Apply</button>
22
+ <% end %>
23
+ </details>
@@ -1,17 +1,7 @@
1
1
  <% if series.blank? %>
2
- <div class="lct-chart-empty">No spend in this range.</div>
2
+ <div class="lct-panel-body lct-muted">No spend in this range.</div>
3
3
  <% else %>
4
- <%= spend_chart_svg(series, comparison_points: local_assigns[:comparison_series]) %>
5
- <div class="lct-chart-legend">
6
- <span><%= series.first[:label] %></span>
7
- <% if local_assigns[:comparison_series].present? %>
8
- <span class="lct-chart-legend-compare">
9
- <span class="lct-chart-key"><span class="lct-chart-key-line"></span> Current</span>
10
- <span class="lct-chart-key"><span class="lct-chart-key-line lct-chart-key-line-secondary"></span> Previous</span>
11
- </span>
12
- <% else %>
13
- <span>Peak <%= money(series.map { |p| p[:cost] }.max) %></span>
14
- <% end %>
15
- <span><%= series.last[:label] %></span>
4
+ <div class="lct-panel-body">
5
+ <%= spend_chart_svg(series, comparison_points: local_assigns[:comparison_series]) %>
16
6
  </div>
17
7
  <% end %>
@@ -7,7 +7,7 @@
7
7
  <% if entry[:more] %>
8
8
  <span class="lct-tag-chip lct-tag-chip-more">+<%= entry[:more] %></span>
9
9
  <% else %>
10
- <span class="lct-tag-chip"><%= entry[:key] %>=<%= entry[:value] %></span>
10
+ <span class="lct-tag-chip"><span class="lct-tag-chip-key"><%= entry[:key] %></span>=<%= entry[:value] %></span>
11
11
  <% end %>
12
12
  <% end %>
13
13
  </span>
@@ -1,18 +1,19 @@
1
- <section class="lct-panel lct-empty">
2
- <h2 class="lct-state-title">Setup required</h2>
3
- <p class="lct-state-copy">
4
- <%= @setup_message || "The llm_cost_tracker_calls table is not available yet." %>
5
- <% if @setup_details.present? %>
6
- Run <span class="lct-code">bin/rails llm_cost_tracker:doctor</span>, apply the listed migrations, and migrate your database.
7
- <% else %>
8
- Run <span class="lct-code">rails generate llm_cost_tracker:install</span> and migrate your database.
9
- <% end %>
10
- </p>
11
- <% if @setup_details.present? %>
12
- <ul class="lct-state-copy">
13
- <% @setup_details.each do |detail| %>
14
- <li><code class="lct-code"><%= detail %></code></li>
1
+ <section class="lct-panel lct-setup-card">
2
+ <div class="lct-panel-head">
3
+ <h2 class="lct-panel-title">Setup required</h2>
4
+ </div>
5
+ <div class="lct-panel-body lct-setup-body">
6
+ <p><%= @setup_message || "The llm_cost_tracker_calls table is not available yet." %></p>
7
+ <p>
8
+ <% if @setup_details.present? %>
9
+ Run <code class="lct-code-id">bin/rails llm_cost_tracker:doctor</code>, apply the listed migrations, and migrate your database.
10
+ <% else %>
11
+ Run <code class="lct-code-id">rails generate llm_cost_tracker:install</code> and migrate your database.
15
12
  <% end %>
16
- </ul>
13
+ </p>
14
+ <p>See <code class="lct-code-id">docs/upgrading.md</code> for the migration path.</p>
15
+ </div>
16
+ <% if @setup_details.present? %>
17
+ <pre class="lct-pre"><%= @setup_details.join("\n") %></pre>
17
18
  <% end %>
18
19
  </section>
@@ -1,46 +1,41 @@
1
- <section class="lct-panel lct-toolbar">
2
- <div class="lct-toolbar-head">
3
- <h2 class="lct-section-title">Tag keys</h2>
4
- </div>
1
+ <div class="lct-filter-row">
2
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: tags_path %>
3
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: tags_path %>
4
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: tags_path %>
5
5
 
6
- <%= render "llm_cost_tracker/shared/filters",
7
- path: tags_path,
8
- fields: %i[from to provider model] %>
6
+ <% if params[:provider].present? || params[:model].present? %>
7
+ <%= link_to "× Clear filters", tags_path(current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
8
+ <% end %>
9
9
 
10
- <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tags_path %>
11
- </section>
10
+ <span class="lct-filter-row-meta"><%= number(@rows.size) %> tag key<%= "s" unless @rows.size == 1 %></span>
11
+ </div>
12
12
 
13
13
  <% if @rows.empty? %>
14
14
  <section class="lct-panel lct-empty">
15
15
  <h2 class="lct-state-title">No tag keys found</h2>
16
16
  <p class="lct-state-copy">Tag keys will appear here once tagged calls exist in the current slice.</p>
17
- <div class="lct-state-actions">
18
- <%= link_to "Clear filters", tags_path, class: "lct-button lct-button-secondary" %>
19
- </div>
20
17
  </section>
21
18
  <% else %>
22
19
  <section class="lct-panel">
23
- <div class="lct-table-wrap">
24
- <table class="lct-table lct-table-compact">
25
- <thead>
20
+ <table class="lct-tbl">
21
+ <thead>
22
+ <tr>
23
+ <th>Tag key</th>
24
+ <th class="lct-num">Calls with this key</th>
25
+ <th class="lct-num">Distinct values</th>
26
+ <th></th>
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ <% @rows.each do |row| %>
26
31
  <tr>
27
- <th>Tag key</th>
28
- <th class="lct-num">Calls with this key</th>
29
- <th class="lct-num">Distinct values</th>
30
- <th></th>
32
+ <td><code class="lct-code-id"><%= row.key %></code></td>
33
+ <td class="lct-num"><%= number(row.calls_count) %></td>
34
+ <td class="lct-num"><%= number(row.distinct_values) %></td>
35
+ <td class="lct-num"><%= link_to "Breakdown", tag_path(row.key, current_query), class: "lct-page-link" %></td>
31
36
  </tr>
32
- </thead>
33
- <tbody>
34
- <% @rows.each do |row| %>
35
- <tr>
36
- <td><code class="lct-code"><%= row.key %></code></td>
37
- <td class="lct-num"><%= number(row.calls_count) %></td>
38
- <td class="lct-num"><%= number(row.distinct_values) %></td>
39
- <td><%= link_to "Breakdown", tag_path(row.key, current_query), class: "lct-button lct-button-secondary lct-button-compact" %></td>
40
- </tr>
41
- <% end %>
42
- </tbody>
43
- </table>
44
- </div>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
45
40
  </section>
46
41
  <% end %>