llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  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/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -0,0 +1,93 @@
1
+ <nav class="lct-tabs" aria-label="Price sources">
2
+ <% LlmCostTracker::Dashboard::PricingOverview::SOURCES.each do |source| %>
3
+ <% data = @overview.fetch(:sources)[source] %>
4
+ <% next unless data %>
5
+ <% active = source == @active_source %>
6
+ <%= link_to pricing_path(source: source), class: "lct-tab#{' lct-active' if active}", aria: (active ? { current: "page" } : {}) do %>
7
+ <%= data.fetch(:label) %><span class="lct-tab-count"><%= number_with_delimiter(data.fetch(:rows).size) %></span>
8
+ <% end %>
9
+ <% end %>
10
+ </nav>
11
+
12
+ <% if @active_source != @overview.fetch(:effective_source) %>
13
+ <% if @active_source == :bundled %>
14
+ <div class="lct-alert lct-alert-info">
15
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
16
+ <span><strong>Fallback source</strong> shipped with the gem. For production, pin a <code>prices_file</code> you control.</span>
17
+ </div>
18
+ <% else %>
19
+ <div class="lct-alert lct-alert-info">
20
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
21
+ <span>A higher-priority source is active — entries here only take effect when not overridden.</span>
22
+ </div>
23
+ <% end %>
24
+ <% end %>
25
+
26
+ <div class="lct-filter-row">
27
+ <details class="lct-filter-pop" name="lct-filter">
28
+ <summary class="lct-filter-pill <%= 'lct-active' if @provider_filter %>">
29
+ <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>
30
+ <span class="lct-filter-pill-key">Provider</span>
31
+ <span class="lct-filter-pill-value"><%= @provider_filter || "All" %></span>
32
+ <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>
33
+ </summary>
34
+ <%= form_with url: pricing_path, method: :get, local: true, html: { class: "lct-filter-pop-body" } do %>
35
+ <%= hidden_field_tag :source, @active_source %>
36
+ <div class="lct-filter-pop-field">
37
+ <label for="lct-filter-provider">Provider</label>
38
+ <%= select_tag :provider, options_for_select(@providers, @provider_filter), include_blank: "All providers", id: "lct-filter-provider" %>
39
+ </div>
40
+ <button type="submit" class="lct-button lct-button-primary">Apply</button>
41
+ <% end %>
42
+ </details>
43
+
44
+ <% if @provider_filter %>
45
+ <%= link_to "× Clear filters", pricing_path(source: @active_source), class: "lct-filter-clear" %>
46
+ <% end %>
47
+
48
+ <span class="lct-filter-row-meta">
49
+ <%= number_with_delimiter(@rows.size) %> entr<%= @rows.size == 1 ? "y" : "ies" %><% if @source_data.fetch(:currency) %> · <%= @source_data.fetch(:currency) %><% end %><% if @source_data.fetch(:updated_at) %> · Updated <%= @source_data.fetch(:updated_at) %><% end %>
50
+ </span>
51
+ </div>
52
+
53
+ <% if @rows.empty? %>
54
+ <section class="lct-panel lct-empty">
55
+ <h2 class="lct-state-title">No prices for this provider</h2>
56
+ <p class="lct-state-copy">Try a different provider filter or clear filters to see the full price table.</p>
57
+ </section>
58
+ <% else %>
59
+ <section class="lct-panel">
60
+ <table class="lct-tbl">
61
+ <thead>
62
+ <tr>
63
+ <th>Provider</th>
64
+ <th>Model</th>
65
+ <th class="lct-num">Input</th>
66
+ <th class="lct-num">Output</th>
67
+ <th class="lct-num">Cache read</th>
68
+ <th class="lct-num">Cache write</th>
69
+ <th class="lct-num">Batch input</th>
70
+ <th class="lct-num">Batch output</th>
71
+ </tr>
72
+ </thead>
73
+ <tbody>
74
+ <% @rows.each do |row| %>
75
+ <tr>
76
+ <td>
77
+ <% if row.provider %>
78
+ <span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= row.provider %>"></span><%= row.provider %></span>
79
+ <% else %>
80
+ <span class="lct-num-muted">—</span>
81
+ <% end %>
82
+ </td>
83
+ <td><code class="lct-code-id"><%= row.model %></code></td>
84
+ <% LlmCostTracker::Dashboard::PricingOverview::RATE_COLUMNS.each do |key| %>
85
+ <% value = row.rates[key] %>
86
+ <td class="lct-num<%= ' lct-num-muted' if value.nil? %>"><%= value ? money(value) : "—" %></td>
87
+ <% end %>
88
+ </tr>
89
+ <% end %>
90
+ </tbody>
91
+ </table>
92
+ </section>
93
+ <% end %>
@@ -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(filter_options_for(:model), 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(filter_options_for(:provider), 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::Dashboard::Filter::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_with_delimiter(@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_with_delimiter(row.calls_count) %></td>
34
+ <td class="lct-num"><%= number_with_delimiter(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 %>
@@ -1,150 +1,131 @@
1
+ <% form_url = tag_path(params[:key]) %>
2
+
1
3
  <% if @value.present? %>
2
- <section class="lct-panel lct-toolbar">
3
- <div class="lct-toolbar-head">
4
- <div>
5
- <p class="lct-muted"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
6
- <h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code> = <code class="lct-code"><%= @value %></code></h2>
7
- </div>
8
- </div>
4
+ <p class="lct-breadcrumb-back"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
5
+ <h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code> = <code class="lct-code-id"><%= @value %></code></h2>
9
6
 
10
- <%= render "llm_cost_tracker/shared/filters",
11
- path: tag_path(params[:key]),
12
- fields: %i[from to provider model],
13
- hidden_fields: { tag_value: @value },
14
- reset_path: tag_path(params[:key], tag_value: @value) %>
15
- </section>
7
+ <div class="lct-filter-row">
8
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
9
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
10
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
11
+
12
+ <% if params[:provider].present? || params[:model].present? %>
13
+ <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
14
+ <% end %>
15
+
16
+ <span class="lct-filter-row-meta"><%= number_with_delimiter(@value_calls) %> call<%= "s" unless @value_calls == 1 %> · <%= money(@value_total_cost) %></span>
17
+ </div>
16
18
 
17
19
  <% if @value_calls.zero? %>
18
20
  <section class="lct-panel lct-empty">
19
21
  <h2 class="lct-state-title">No calls tagged with <%= params[:key] %>=<%= @value %></h2>
20
22
  <p class="lct-state-copy">No matching calls in the current slice.</p>
21
- <div class="lct-state-actions">
22
- <%= link_to "Back to values", tag_path(params[:key]), class: "lct-button lct-button-secondary" %>
23
- </div>
24
23
  </section>
25
24
  <% else %>
26
- <section class="lct-stat-grid lct-stat-grid-spaced">
27
- <article class="lct-stat">
28
- <p class="lct-stat-label">Total cost</p>
25
+ <div class="lct-stat-grid">
26
+ <div class="lct-stat">
27
+ <div class="lct-stat-head"><p class="lct-stat-label">Total cost</p></div>
29
28
  <p class="lct-stat-value"><%= money(@value_total_cost) %></p>
30
- <p class="lct-stat-copy">Across <%= number(@value_calls) %> calls</p>
31
- </article>
32
-
33
- <article class="lct-stat">
34
- <p class="lct-stat-label">Calls</p>
35
- <p class="lct-stat-value"><%= number(@value_calls) %></p>
36
- <p class="lct-stat-copy">Tagged with <code class="lct-code"><%= @value %></code></p>
37
- </article>
38
-
39
- <article class="lct-stat">
40
- <p class="lct-stat-label">Avg cost / call</p>
29
+ <p class="lct-stat-foot">Across <%= number_with_delimiter(@value_calls) %> calls</p>
30
+ </div>
31
+ <div class="lct-stat">
32
+ <div class="lct-stat-head"><p class="lct-stat-label">Calls</p></div>
33
+ <p class="lct-stat-value"><%= number_with_delimiter(@value_calls) %></p>
34
+ <p class="lct-stat-foot">Tagged with <code class="lct-code-id"><%= @value %></code></p>
35
+ </div>
36
+ <div class="lct-stat">
37
+ <div class="lct-stat-head"><p class="lct-stat-label">Avg cost / call</p></div>
41
38
  <p class="lct-stat-value"><%= money(@value_calls.positive? ? @value_total_cost / @value_calls : 0) %></p>
42
- <p class="lct-stat-copy">Mean over the slice</p>
43
- </article>
44
- </section>
39
+ </div>
40
+ </div>
45
41
 
46
42
  <section class="lct-panel">
47
- <div class="lct-section-head">
48
- <div>
49
- <h2 class="lct-section-title">Spend over time</h2>
50
- <p class="lct-section-copy">Daily total cost for calls tagged <code class="lct-code"><%= params[:key] %>=<%= @value %></code>.</p>
51
- </div>
52
- <%= link_to "Calls", calls_path(calls_query_for_tag(key: @key, value: @value)), class: "lct-button lct-button-secondary lct-button-compact" %>
43
+ <div class="lct-panel-head">
44
+ <h2 class="lct-panel-title">Spend over time</h2>
45
+ <span class="lct-panel-meta"><%= link_to "View calls →", calls_path(calls_query_for_tag(key: @key, value: @value)) %></span>
53
46
  </div>
54
-
55
- <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
47
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points, comparison_series: [] %>
56
48
  </section>
57
49
  <% end %>
50
+
58
51
  <% else %>
59
- <section class="lct-panel lct-toolbar">
60
- <div class="lct-toolbar-head">
61
- <div>
62
- <p class="lct-muted"><%= link_to "← All tag keys", tags_path(current_query) %></p>
63
- <h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code></h2>
64
- </div>
65
- </div>
66
52
 
67
- <%= render "llm_cost_tracker/shared/filters",
68
- path: tag_path(params[:key]),
69
- fields: %i[from to provider model],
70
- reset_path: tag_path(params[:key]) %>
53
+ <p class="lct-breadcrumb-back"><%= link_to "← All tag keys", tags_path(current_query) %></p>
54
+ <h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code></h2>
71
55
 
72
- <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tag_path(params[:key]) %>
56
+ <div class="lct-filter-row">
57
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url %>
58
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url %>
59
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url %>
73
60
 
74
- <p class="lct-summary-row">
75
- <span><strong><%= number(@breakdown.tagged_calls) %></strong> tagged calls</span>
76
- <span><strong><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></strong> coverage</span>
77
- <span><strong><%= number(@breakdown.distinct_values) %></strong> distinct values</span>
78
- </p>
61
+ <% if params[:provider].present? || params[:model].present? %>
62
+ <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
63
+ <% end %>
79
64
 
80
- <% if @breakdown.distinct_values > @breakdown.rows.size %>
81
- <p class="lct-toolbar-note">Showing top <%= number(@breakdown.limit) %> values by spend.</p>
82
- <% end %>
83
- </section>
65
+ <span class="lct-filter-row-meta"><%= number_with_delimiter(@breakdown.tagged_calls) %> tagged call<%= "s" unless @breakdown.tagged_calls == 1 %> · <%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %> coverage · <%= number_with_delimiter(@breakdown.distinct_values) %> distinct value<%= "s" unless @breakdown.distinct_values == 1 %></span>
66
+ </div>
84
67
 
85
- <% if @breakdown.rows.empty? %>
86
- <section class="lct-panel lct-empty">
87
- <h2 class="lct-state-title">No calls tagged with <%= params[:key] %></h2>
88
- <p class="lct-state-copy">Values for this key will appear here once matching calls carry the tag in the current slice.</p>
89
- <div class="lct-state-actions">
90
- <%= link_to "Clear filters", tag_path(params[:key]), class: "lct-button lct-button-secondary" %>
68
+ <% if @breakdown.rows.empty? %>
69
+ <section class="lct-panel lct-empty">
70
+ <h2 class="lct-state-title">No calls tagged with <%= params[:key] %></h2>
71
+ <p class="lct-state-copy">Values for this key will appear here once matching calls carry the tag in the current slice.</p>
72
+ </section>
73
+ <% else %>
74
+ <div class="lct-stat-grid">
75
+ <div class="lct-stat">
76
+ <div class="lct-stat-head"><p class="lct-stat-label">Tagged calls</p></div>
77
+ <p class="lct-stat-value"><%= number_with_delimiter(@breakdown.tagged_calls) %></p>
78
+ <p class="lct-stat-foot">Rows that include <code class="lct-code-id"><%= params[:key] %></code></p>
79
+ </div>
80
+ <div class="lct-stat">
81
+ <div class="lct-stat-head"><p class="lct-stat-label">Coverage</p></div>
82
+ <p class="lct-stat-value"><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></p>
83
+ <p class="lct-stat-foot"><%= number_with_delimiter(@breakdown.total_calls) %> total calls in this slice</p>
84
+ </div>
85
+ <div class="lct-stat">
86
+ <div class="lct-stat-head"><p class="lct-stat-label">Distinct values</p></div>
87
+ <p class="lct-stat-value"><%= number_with_delimiter(@breakdown.distinct_values) %></p>
88
+ </div>
91
89
  </div>
92
- </section>
93
- <% else %>
94
- <section class="lct-stat-grid lct-stat-grid-spaced">
95
- <article class="lct-stat">
96
- <p class="lct-stat-label">Tagged calls</p>
97
- <p class="lct-stat-value"><%= number(@breakdown.tagged_calls) %></p>
98
- <p class="lct-stat-copy">Rows that include <code class="lct-code"><%= params[:key] %></code></p>
99
- </article>
100
-
101
- <article class="lct-stat">
102
- <p class="lct-stat-label">Coverage</p>
103
- <p class="lct-stat-value"><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></p>
104
- <p class="lct-stat-copy"><%= number(@breakdown.total_calls) %> total calls in this slice</p>
105
- </article>
106
90
 
107
- <article class="lct-stat">
108
- <p class="lct-stat-label">Distinct values</p>
109
- <p class="lct-stat-value"><%= number(@breakdown.distinct_values) %></p>
110
- <p class="lct-stat-copy">Unique values currently visible</p>
111
- </article>
112
- </section>
91
+ <% if @breakdown.distinct_values > @breakdown.rows.size %>
92
+ <div class="lct-alert lct-alert-info">
93
+ <span>Showing top <%= number_with_delimiter(@breakdown.limit) %> values by spend.</span>
94
+ </div>
95
+ <% end %>
113
96
 
114
- <section class="lct-panel">
115
- <div class="lct-table-wrap">
116
- <table class="lct-table lct-table-compact">
97
+ <section class="lct-panel">
98
+ <table class="lct-tbl">
117
99
  <thead>
118
100
  <tr>
119
- <th>Value</th>
120
- <th class="lct-num">Calls</th>
101
+ <%= sortable_header("Value", "value") %>
102
+ <%= sortable_header("Calls", "calls", num: true) %>
121
103
  <th class="lct-num">Share</th>
122
- <th class="lct-num">Total cost</th>
123
- <th class="lct-num">Avg cost / call</th>
104
+ <%= sortable_header("Total cost", "cost", num: true, default: true) %>
105
+ <%= sortable_header("Avg cost / call", "avg_cost", num: true) %>
124
106
  <th></th>
125
107
  </tr>
126
108
  </thead>
127
109
  <tbody>
128
110
  <% @breakdown.rows.each do |row| %>
129
111
  <tr>
130
- <td><code class="lct-code"><%= row.value %></code></td>
131
- <td class="lct-num"><%= number(row.calls) %></td>
112
+ <td><code class="lct-code-id"><%= row.value %></code></td>
113
+ <td class="lct-num"><%= number_with_delimiter(row.calls) %></td>
132
114
  <td class="lct-num"><%= percent(row.share_percent) %></td>
133
115
  <td class="lct-num"><%= money(row.total_cost) %></td>
134
116
  <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
135
- <td>
117
+ <td class="lct-num">
136
118
  <% if row.value == "(untagged)" %>
137
- <span class="lct-muted">n/a</span>
119
+ <span class="lct-num-muted">n/a</span>
138
120
  <% else %>
139
- <%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
140
- <%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
121
+ <%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-page-link" %>
122
+ <%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-page-link" %>
141
123
  <% end %>
142
124
  </td>
143
125
  </tr>
144
126
  <% end %>
145
127
  </tbody>
146
128
  </table>
147
- </div>
148
- </section>
149
- <% end %>
129
+ </section>
130
+ <% end %>
150
131
  <% end %>
data/config/routes.rb CHANGED
@@ -6,9 +6,9 @@ LlmCostTracker::Engine.routes.draw do
6
6
  resources :models, only: :index
7
7
  resources :tags, only: %i[index show], param: :key, format: false
8
8
  get "data_quality", to: "data_quality#index", as: :data_quality
9
- get "reconciliation", to: "reconciliation#index", as: :reconciliation
10
- post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
9
+ get "pricing", to: "pricing#index", as: :pricing
11
10
 
12
11
  get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
13
- to: "assets#stylesheet", as: :stylesheet
12
+ to: "assets#stylesheet",
13
+ as: :stylesheet
14
14
  end