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
@@ -1,195 +1,152 @@
1
- <section class="lct-panel lct-toolbar">
2
- <div class="lct-toolbar-head">
3
- <h2 class="lct-section-title">Data Quality</h2>
4
- </div>
1
+ <div class="lct-filter-row">
2
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: data_quality_path %>
3
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: data_quality_path %>
4
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: data_quality_path %>
5
5
 
6
- <%= render "llm_cost_tracker/shared/filters", path: data_quality_path %>
6
+ <% if params[:provider].present? || params[:model].present? %>
7
+ <%= link_to "× Clear filters", data_quality_path(current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
8
+ <% end %>
7
9
 
8
- <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: data_quality_path %>
9
- </section>
10
+ <span class="lct-filter-row-meta"><%= number_with_delimiter(@summary.total) %> call<%= "s" unless @summary.total == 1 %> inspected</span>
11
+ </div>
10
12
 
11
13
  <% if @summary.total.zero? %>
12
14
  <section class="lct-panel lct-empty">
13
15
  <h2 class="lct-state-title">No data yet</h2>
14
16
  <p class="lct-state-copy">Quality metrics will appear here once calls are recorded in the current slice.</p>
15
- <div class="lct-state-actions">
16
- <%= link_to "Clear filters", data_quality_path, class: "lct-button lct-button-secondary" %>
17
- </div>
18
17
  </section>
19
18
  <% else %>
20
- <section class="lct-stat-grid lct-stat-grid-spaced">
21
- <article class="lct-stat">
22
- <p class="lct-stat-label">Calls inspected</p>
23
- <p class="lct-stat-value"><%= number(@summary.total) %></p>
24
- <p class="lct-stat-sub">in current slice</p>
25
- </article>
26
-
27
- <article class="lct-stat">
28
- <p class="lct-stat-label">Unknown pricing</p>
29
- <p class="lct-stat-value"><%= number(@summary.unknown_pricing_count) %></p>
30
- <p class="lct-stat-sub"><%= percent(@summary.unknown_pricing_share) %> of calls</p>
31
- </article>
32
-
33
- <article class="lct-stat">
34
- <p class="lct-stat-label">Calls without tags</p>
35
- <p class="lct-stat-value"><%= number(@summary.untagged_calls_count) %></p>
36
- <p class="lct-stat-sub"><%= percent(@summary.untagged_share) %> of calls</p>
37
- </article>
19
+ <h3 class="lct-stat-section-label">Volume</h3>
20
+ <div class="lct-stat-grid">
21
+ <div class="lct-stat">
22
+ <div class="lct-stat-head"><p class="lct-stat-label">Calls inspected</p></div>
23
+ <p class="lct-stat-value"><%= number_with_delimiter(@summary.total) %></p>
24
+ <p class="lct-stat-foot">in current slice</p>
25
+ </div>
38
26
 
39
- <article class="lct-stat">
40
- <p class="lct-stat-label">Missing latency</p>
41
- <p class="lct-stat-value"><%= number(@summary.missing_latency_count) %></p>
42
- <p class="lct-stat-sub"><%= percent(@summary.missing_latency_share) %> of calls</p>
43
- </article>
27
+ <div class="lct-stat">
28
+ <div class="lct-stat-head"><p class="lct-stat-label">Streaming calls</p></div>
29
+ <p class="lct-stat-value"><%= number_with_delimiter(@summary.streaming_count) %></p>
30
+ <p class="lct-stat-foot"><%= percent(@summary.streaming_share) %> of calls</p>
31
+ </div>
44
32
 
45
- <article class="lct-stat">
46
- <p class="lct-stat-label">Streaming calls</p>
47
- <p class="lct-stat-value"><%= number(@summary.streaming_count) %></p>
48
- <p class="lct-stat-sub"><%= percent(@summary.streaming_share) %> of calls</p>
49
- </article>
33
+ <div class="lct-stat">
34
+ <div class="lct-stat-head"><p class="lct-stat-label">Calls with provider response ID</p></div>
35
+ <p class="lct-stat-value"><%= number_with_delimiter(@summary.calls_with_provider_response_id) %></p>
36
+ <p class="lct-stat-foot"><%= percent(@summary.provider_response_id_coverage) %> of calls</p>
37
+ </div>
38
+ </div>
50
39
 
51
- <% if @summary.streaming_count.positive? %>
52
- <article class="lct-stat">
53
- <p class="lct-stat-label">Streams without usage</p>
54
- <p class="lct-stat-value"><%= number(@summary.streaming_missing_usage) %></p>
55
- <p class="lct-stat-sub"><%= percent(@summary.streaming_missing_usage_share) %> of streams</p>
56
- </article>
57
- <% end %>
40
+ <% incomplete_pricing = @summary.unknown_pricing_count.to_i %>
41
+ <% untagged = @summary.untagged_calls_count.to_i %>
42
+ <% missing_latency = @summary.missing_latency_count.to_i %>
43
+ <% streams_missing_usage = @summary.streaming_missing_usage.to_i %>
44
+ <% hidden_output_share = @hidden_output_summary&.fetch(:share_percent).to_f %>
45
+ <% has_issues = incomplete_pricing.positive? || untagged.positive? || missing_latency.positive? || streams_missing_usage.positive? || hidden_output_share.positive? %>
58
46
 
59
- <article class="lct-stat">
60
- <p class="lct-stat-label">Calls with provider response ID</p>
61
- <p class="lct-stat-value"><%= number(@summary.calls_with_provider_response_id) %></p>
62
- <p class="lct-stat-sub"><%= percent(@summary.provider_response_id_coverage) %> of calls</p>
63
- </article>
47
+ <% if has_issues %>
48
+ <h3 class="lct-stat-section-label">Issues</h3>
49
+ <div class="lct-stat-grid">
50
+ <% if incomplete_pricing.positive? %>
51
+ <div class="lct-stat lct-stat-warn">
52
+ <div class="lct-stat-head"><p class="lct-stat-label">Incomplete pricing</p></div>
53
+ <p class="lct-stat-value"><%= number_with_delimiter(incomplete_pricing) %></p>
54
+ <p class="lct-stat-foot"><%= percent(@summary.unknown_pricing_share) %> of calls</p>
55
+ </div>
56
+ <% end %>
64
57
 
65
- <% if @hidden_output_summary %>
66
- <article class="lct-stat">
67
- <p class="lct-stat-label">Hidden output share</p>
68
- <p class="lct-stat-value"><%= percent(@hidden_output_summary.fetch(:share_percent)) %></p>
69
- <p class="lct-stat-sub"><%= number(@hidden_output_summary.fetch(:hidden_output_tokens)) %> of <%= number(@hidden_output_summary.fetch(:output_tokens)) %> output tokens</p>
70
- </article>
71
- <% end %>
72
- </section>
58
+ <% if untagged.positive? %>
59
+ <div class="lct-stat lct-stat-warn">
60
+ <div class="lct-stat-head"><p class="lct-stat-label">Calls without tags</p></div>
61
+ <p class="lct-stat-value"><%= number_with_delimiter(untagged) %></p>
62
+ <p class="lct-stat-foot"><%= percent(@summary.untagged_share) %> of calls</p>
63
+ </div>
64
+ <% end %>
73
65
 
74
- <section class="lct-grid lct-two-col">
75
- <section class="lct-panel">
76
- <div class="lct-section-head">
77
- <div>
78
- <h2 class="lct-section-title">Next actions</h2>
79
- <p class="lct-section-copy">Use these fixes to improve the trustworthiness of downstream cost reports.</p>
66
+ <% if missing_latency.positive? %>
67
+ <div class="lct-stat lct-stat-warn">
68
+ <div class="lct-stat-head"><p class="lct-stat-label">Missing latency</p></div>
69
+ <p class="lct-stat-value"><%= number_with_delimiter(missing_latency) %></p>
70
+ <p class="lct-stat-foot"><%= percent(@summary.missing_latency_share) %> of calls</p>
80
71
  </div>
81
- </div>
72
+ <% end %>
82
73
 
83
- <table class="lct-table lct-table-compact">
84
- <thead>
85
- <tr>
86
- <th>Issue</th>
87
- <th>Why it matters</th>
88
- <th>Suggested action</th>
89
- </tr>
90
- </thead>
91
- <tbody>
92
- <tr>
93
- <td>Unknown pricing</td>
94
- <td>Cost stays <code class="lct-code">nil</code>, so totals undercount.</td>
95
- <td>Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code>.</td>
96
- </tr>
97
- <tr>
98
- <td>Missing tags</td>
99
- <td>Attribution by tenant, user, or feature becomes less useful.</td>
100
- <td>Pass <code class="lct-code">tags:</code> from middleware using request context.</td>
101
- </tr>
102
- <tr>
103
- <td>Missing latency</td>
104
- <td>Slow requests become harder to isolate on the calls page.</td>
105
- <td>Make sure latency capture is enabled on every tracked request.</td>
106
- </tr>
107
- <% if @summary.streaming_missing_usage.positive? %>
108
- <tr>
109
- <td>Streams without usage</td>
110
- <td>Token totals undercount when streaming responses drop the final usage event.</td>
111
- <td>Send OpenAI requests with <code class="lct-code">stream_options: { include_usage: true }</code>, or wrap custom clients with <code class="lct-code">LlmCostTracker.track_stream</code>.</td>
112
- </tr>
113
- <% end %>
114
- <% if @summary.missing_provider_response_id_count.positive? %>
115
- <tr>
116
- <td>Missing provider response IDs</td>
117
- <td>Proof of provider-issued responses is weaker when calls cannot be tied back to provider objects.</td>
118
- <td>Upgrade to the latest parser coverage and pass <code class="lct-code">provider_response_id:</code> for custom clients when the provider exposes one.</td>
119
- </tr>
120
- <% end %>
121
- </tbody>
122
- </table>
123
- </section>
74
+ <% if streams_missing_usage.positive? %>
75
+ <div class="lct-stat lct-stat-warn">
76
+ <div class="lct-stat-head"><p class="lct-stat-label">Streams without usage</p></div>
77
+ <p class="lct-stat-value"><%= number_with_delimiter(streams_missing_usage) %></p>
78
+ <p class="lct-stat-foot"><%= percent(@summary.streaming_missing_usage_share) %> of streams</p>
79
+ </div>
80
+ <% end %>
124
81
 
125
- <section class="lct-panel">
126
- <div class="lct-section-head">
127
- <div>
128
- <h2 class="lct-section-title">Coverage summary</h2>
129
- <p class="lct-section-copy">Good dashboards start with clean pricing, tags, and latency coverage.</p>
82
+ <% if hidden_output_share.positive? %>
83
+ <div class="lct-stat lct-stat-warn">
84
+ <div class="lct-stat-head"><p class="lct-stat-label">Hidden output share</p></div>
85
+ <p class="lct-stat-value"><%= percent(hidden_output_share) %></p>
86
+ <p class="lct-stat-foot"><%= number_with_delimiter(@hidden_output_summary.fetch(:hidden_output_tokens)) %> of <%= number_with_delimiter(@hidden_output_summary.fetch(:output_tokens)) %> output tokens</p>
130
87
  </div>
131
- </div>
88
+ <% end %>
89
+ </div>
90
+ <% end %>
132
91
 
133
- <table class="lct-table lct-table-compact">
134
- <thead>
135
- <tr>
136
- <th>Dimension</th>
137
- <th class="lct-num">Coverage</th>
138
- <th class="lct-num">Calls with data</th>
139
- <th>Visual</th>
140
- </tr>
141
- </thead>
142
- <tbody>
143
- <tr>
144
- <td>Cost (pricing known)</td>
145
- <td class="lct-num"><%= percent(@summary.cost_coverage) %></td>
146
- <td class="lct-num"><%= number(@summary.calls_with_pricing) %></td>
147
- <td><%= render "llm_cost_tracker/shared/bar", value: @summary.cost_coverage, max: 100.0 %></td>
148
- </tr>
92
+ <section class="lct-panel">
93
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Coverage summary</h2></div>
149
94
 
150
- <tr>
151
- <td>Tags (at least one tag)</td>
152
- <td class="lct-num"><%= percent(@summary.tag_coverage) %></td>
153
- <td class="lct-num"><%= number(@summary.tagged_calls) %></td>
154
- <td><%= render "llm_cost_tracker/shared/bar", value: @summary.tag_coverage, max: 100.0 %></td>
155
- </tr>
95
+ <table class="lct-tbl">
96
+ <thead>
97
+ <tr>
98
+ <th>Dimension</th>
99
+ <th class="lct-num">Coverage</th>
100
+ <th class="lct-num">Calls with data</th>
101
+ <th>Visual</th>
102
+ </tr>
103
+ </thead>
104
+ <tbody>
105
+ <tr>
106
+ <td>Cost (pricing known)</td>
107
+ <td class="lct-num"><%= percent(@summary.cost_coverage) %></td>
108
+ <td class="lct-num"><%= number_with_delimiter(@summary.calls_with_pricing) %></td>
109
+ <td><%= render "llm_cost_tracker/shared/bar", value: @summary.cost_coverage, max: 100.0 %></td>
110
+ </tr>
156
111
 
157
- <tr>
158
- <td>Latency</td>
159
- <td class="lct-num"><%= percent(@summary.latency_coverage) %></td>
160
- <td class="lct-num"><%= number(@summary.calls_with_latency) %></td>
161
- <td><%= render "llm_cost_tracker/shared/bar", value: @summary.latency_coverage, max: 100.0 %></td>
162
- </tr>
112
+ <tr>
113
+ <td>Tags (at least one tag)</td>
114
+ <td class="lct-num"><%= percent(@summary.tag_coverage) %></td>
115
+ <td class="lct-num"><%= number_with_delimiter(@summary.tagged_calls) %></td>
116
+ <td><%= render "llm_cost_tracker/shared/bar", value: @summary.tag_coverage, max: 100.0 %></td>
117
+ </tr>
163
118
 
164
- <% if @summary.streaming_count.positive? %>
165
- <tr>
166
- <td>Streaming usage captured</td>
167
- <td class="lct-num"><%= percent(@summary.stream_coverage) %></td>
168
- <td class="lct-num"><%= number(@summary.streams_with_usage) %> / <%= number(@summary.streaming_count) %></td>
169
- <td><%= render "llm_cost_tracker/shared/bar", value: @summary.stream_coverage, max: 100.0 %></td>
170
- </tr>
171
- <% end %>
119
+ <tr>
120
+ <td>Latency</td>
121
+ <td class="lct-num"><%= percent(@summary.latency_coverage) %></td>
122
+ <td class="lct-num"><%= number_with_delimiter(@summary.calls_with_latency) %></td>
123
+ <td><%= render "llm_cost_tracker/shared/bar", value: @summary.latency_coverage, max: 100.0 %></td>
124
+ </tr>
172
125
 
126
+ <% if @summary.streaming_count.positive? %>
173
127
  <tr>
174
- <td>Provider response ID</td>
175
- <td class="lct-num"><%= percent(@summary.provider_response_id_coverage) %></td>
176
- <td class="lct-num"><%= number(@summary.calls_with_provider_response_id) %></td>
177
- <td><%= render "llm_cost_tracker/shared/bar", value: @summary.provider_response_id_coverage, max: 100.0 %></td>
128
+ <td>Streaming usage captured</td>
129
+ <td class="lct-num"><%= percent(@summary.stream_coverage) %></td>
130
+ <td class="lct-num"><%= number_with_delimiter(@summary.streams_with_usage) %> / <%= number_with_delimiter(@summary.streaming_count) %></td>
131
+ <td><%= render "llm_cost_tracker/shared/bar", value: @summary.stream_coverage, max: 100.0 %></td>
178
132
  </tr>
179
- </tbody>
180
- </table>
181
- </section>
133
+ <% end %>
134
+
135
+ <tr>
136
+ <td>Provider response ID</td>
137
+ <td class="lct-num"><%= percent(@summary.provider_response_id_coverage) %></td>
138
+ <td class="lct-num"><%= number_with_delimiter(@summary.calls_with_provider_response_id) %></td>
139
+ <td><%= render "llm_cost_tracker/shared/bar", value: @summary.provider_response_id_coverage, max: 100.0 %></td>
140
+ </tr>
141
+ </tbody>
142
+ </table>
182
143
  </section>
183
144
 
184
145
  <section class="lct-panel">
185
- <div class="lct-section-head">
186
- <div>
187
- <h2 class="lct-section-title">Token usage</h2>
188
- </div>
189
- </div>
146
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Token usage</h2></div>
190
147
 
191
148
  <div class="lct-table-wrap">
192
- <table class="lct-table lct-table-compact">
149
+ <table class="lct-tbl">
193
150
  <thead>
194
151
  <tr>
195
152
  <th>Bucket</th>
@@ -205,7 +162,7 @@
205
162
  <% cost_value = row.fetch(:cost_value) %>
206
163
  <tr>
207
164
  <td><%= LlmCostTracker::TokenUsageHelper::QUALITY_LABELS.fetch(token_key) %></td>
208
- <td class="lct-num"><%= number(row.fetch(:token_value)) %></td>
165
+ <td class="lct-num"><%= number_with_delimiter(row.fetch(:token_value)) %></td>
209
166
  <% if row.fetch(:share_basis) == :output %>
210
167
  <td class="lct-num"><%= percent(row.fetch(:share_percent)) %> of output</td>
211
168
  <% else %>
@@ -223,14 +180,10 @@
223
180
 
224
181
  <% if @service_charge_rows.any? %>
225
182
  <section class="lct-panel">
226
- <div class="lct-section-head">
227
- <div>
228
- <h2 class="lct-section-title">Service charges</h2>
229
- </div>
230
- </div>
183
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Service charges</h2></div>
231
184
 
232
185
  <div class="lct-table-wrap">
233
- <table class="lct-table lct-table-compact">
186
+ <table class="lct-tbl">
234
187
  <thead>
235
188
  <tr>
236
189
  <th>Provider</th>
@@ -243,13 +196,13 @@
243
196
  </thead>
244
197
  <tbody>
245
198
  <% @service_charge_rows.each do |row| %>
246
- <% unknown_cost = row.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
199
+ <% unknown_cost = row.cost_status.to_s == LlmCostTracker::Charges::CostStatus::UNKNOWN %>
247
200
  <tr>
248
- <td><code class="lct-code"><%= row.provider %></code></td>
249
- <td><code class="lct-code"><%= row.component %></code></td>
201
+ <td><code class="lct-code-id"><%= row.provider %></code></td>
202
+ <td><code class="lct-code-id"><%= row.component %></code></td>
250
203
  <td><%= row.cost_status %></td>
251
- <td class="lct-num"><%= number(row.charges_count) %></td>
252
- <td class="lct-num"><%= number(row.quantity) %></td>
204
+ <td class="lct-num"><%= number_with_delimiter(row.charges_count) %></td>
205
+ <td class="lct-num"><%= number_with_delimiter(row.quantity) %></td>
253
206
  <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(row.total_cost) %></td>
254
207
  </tr>
255
208
  <% end %>
@@ -261,15 +214,10 @@
261
214
 
262
215
  <% if @streaming_health_rows.any? %>
263
216
  <section class="lct-panel">
264
- <div class="lct-section-head">
265
- <div>
266
- <h2 class="lct-section-title">Streaming health by provider</h2>
267
- <p class="lct-section-copy">Streams without a final usage chunk land as <code class="lct-code">usage_source: unknown</code> and undercount tokens. A high unknown share for an OpenAI-compatible provider usually means <code class="lct-code">stream_options: { include_usage: true }</code> is not being injected for that host.</p>
268
- </div>
269
- </div>
217
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Streaming health by provider</h2></div>
270
218
 
271
219
  <div class="lct-table-wrap">
272
- <table class="lct-table lct-table-compact">
220
+ <table class="lct-tbl">
273
221
  <thead>
274
222
  <tr>
275
223
  <th>Provider</th>
@@ -282,10 +230,10 @@
282
230
  <tbody>
283
231
  <% @streaming_health_rows.each do |row| %>
284
232
  <tr>
285
- <td><code class="lct-code"><%= row.provider %></code></td>
286
- <td class="lct-num"><%= number(row.streams) %></td>
287
- <td class="lct-num"><%= number(row.with_usage) %></td>
288
- <td class="lct-num"><%= number(row.unknown) %></td>
233
+ <td><code class="lct-code-id"><%= row.provider %></code></td>
234
+ <td class="lct-num"><%= number_with_delimiter(row.streams) %></td>
235
+ <td class="lct-num"><%= number_with_delimiter(row.with_usage) %></td>
236
+ <td class="lct-num"><%= number_with_delimiter(row.unknown) %></td>
289
237
  <td class="lct-num"><%= percent(row.unknown_share) %></td>
290
238
  </tr>
291
239
  <% end %>
@@ -297,16 +245,14 @@
297
245
 
298
246
  <% unless @unknown_pricing_by_model.empty? %>
299
247
  <section class="lct-panel">
300
- <div class="lct-section-head">
301
- <div>
302
- <h2 class="lct-section-title">Unknown pricing by model</h2>
303
- <p class="lct-section-copy">These models have token counts but no configured rates, so cost stays unknown. After the next price refresh or a <code class="lct-code">pricing_overrides</code> update, run <code class="lct-code">bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls.</p>
304
- </div>
305
- <%= link_to "Calls", calls_path(current_query(sort: "unknown_pricing")), class: "lct-button lct-button-secondary" %>
248
+ <div class="lct-panel-head">
249
+ <h2 class="lct-panel-title">Incomplete pricing by model</h2>
250
+ <span class="lct-panel-meta"><%= link_to "View calls →", calls_path(current_query(cost_status: "incomplete", sort: nil)) %></span>
306
251
  </div>
252
+ <p class="lct-panel-intro">These models have line items without configured rates, so totals undercount (a full row is missing all rates; a partial row has some line items priced and others not). After the next price refresh or a <code>pricing_overrides</code> update, run <code>bin/rails llm_cost_tracker:backfill_unknown_pricing</code> to recompute these calls.</p>
307
253
 
308
254
  <div class="lct-table-wrap">
309
- <table class="lct-table lct-table-compact">
255
+ <table class="lct-tbl">
310
256
  <thead>
311
257
  <tr>
312
258
  <th>Provider</th>
@@ -318,9 +264,9 @@
318
264
  <tbody>
319
265
  <% @unknown_pricing_by_model.each do |row| %>
320
266
  <tr>
321
- <td><code class="lct-code"><%= row.provider %></code></td>
322
- <td><code class="lct-code"><%= row.model %></code></td>
323
- <td class="lct-num"><%= number(row.calls) %></td>
267
+ <td><code class="lct-code-id"><%= row.provider %></code></td>
268
+ <td><code class="lct-code-id"><%= row.model %></code></td>
269
+ <td class="lct-num"><%= number_with_delimiter(row.calls) %></td>
324
270
  <td class="lct-num"><%= percent(row.share_percent) %></td>
325
271
  </tr>
326
272
  <% end %>
@@ -1,8 +1,8 @@
1
1
  <section class="lct-panel lct-empty">
2
2
  <h2 class="lct-state-title">Database unavailable</h2>
3
3
  <p class="lct-state-copy">
4
- llm_cost_tracker could not read the <span class="lct-code">llm_cost_tracker_calls</span> table.
4
+ llm_cost_tracker could not read the <code class="lct-code-id">llm_cost_tracker_calls</code> table.
5
5
  Check that ActiveRecord is connected, then run
6
- <span class="lct-code">rails generate llm_cost_tracker:install</span> and migrate your database.
6
+ <code class="lct-code-id">rails generate llm_cost_tracker:install</code> and migrate your database.
7
7
  </p>
8
8
  </section>
@@ -1,71 +1,51 @@
1
- <section class="lct-panel lct-toolbar">
2
- <div class="lct-toolbar-head">
3
- <h2 class="lct-section-title">Models</h2>
4
- </div>
1
+ <% rows = @rows.to_a %>
2
+ <div class="lct-filter-row">
3
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: models_path %>
4
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: models_path %>
5
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: models_path %>
5
6
 
6
- <%= render "llm_cost_tracker/shared/filters",
7
- path: models_path,
8
- fields: %i[from to provider model] %>
7
+ <% if params[:provider].present? || params[:model].present? %>
8
+ <%= link_to "× Clear filters", models_path(current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
9
+ <% end %>
9
10
 
10
- <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: models_path %>
11
- </section>
11
+ <span class="lct-filter-row-meta"><%= number_with_delimiter(rows.size) %> model<%= "s" unless rows.size == 1 %></span>
12
+ </div>
12
13
 
13
- <% if @rows.empty? %>
14
+ <% if rows.empty? %>
14
15
  <section class="lct-panel lct-empty">
15
16
  <h2 class="lct-state-title">No models in this slice</h2>
16
- <p class="lct-state-copy">Tracked models will appear here once calls match the current provider, model, and date filters.</p>
17
- <div class="lct-state-actions">
18
- <%= link_to "Clear filters", models_path, class: "lct-button lct-button-secondary" %>
19
- </div>
17
+ <p class="lct-state-copy">Tracked models will appear here once calls match the current filters.</p>
20
18
  </section>
21
19
  <% else %>
22
20
  <section class="lct-panel">
23
- <div class="lct-results-toolbar">
24
- <%= render "llm_cost_tracker/shared/sort",
25
- current: (@sort.presence || "cost"),
26
- options: [
27
- ["Total spend", "cost"],
28
- ["Call volume", "calls"],
29
- ["Avg cost / call", "avg_cost"],
30
- ["Avg latency", "latency"]
31
- ],
32
- path_for_sort: ->(value) { models_path(current_query(sort: value)) } %>
33
- </div>
34
-
35
- <div class="lct-table-wrap">
36
- <table class="lct-table lct-table-compact">
37
- <thead>
21
+ <table class="lct-tbl">
22
+ <thead>
23
+ <tr>
24
+ <%= sortable_header("Provider", "provider") %>
25
+ <%= sortable_header("Model", "name") %>
26
+ <%= sortable_header("Calls", "calls", num: true) %>
27
+ <%= sortable_header("Tokens", "tokens", num: true) %>
28
+ <%= sortable_header("Avg latency", "latency", num: true) %>
29
+ <%= sortable_header("Avg cost / call", "avg_cost", num: true) %>
30
+ <%= sortable_header("Total cost", "cost", num: true, default: true) %>
31
+ <th></th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <% rows.each do |row| %>
38
36
  <tr>
39
- <th>Provider</th>
40
- <th>Model</th>
41
- <th class="lct-num">Calls</th>
42
- <th class="lct-num">Total tokens</th>
43
- <th class="lct-num">Regular input</th>
44
- <th class="lct-num">Output</th>
45
- <th class="lct-num">Total cost</th>
46
- <th class="lct-num">Avg cost / call</th>
47
- <th class="lct-num">Avg latency</th>
48
- <th></th>
37
+ <td><span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= row.provider %>"></span><%= row.provider %></span></td>
38
+ <td><code class="lct-code-id"><%= row.model %></code></td>
39
+ <td class="lct-num"><%= number_with_delimiter(row.calls) %></td>
40
+ <td class="lct-num"><%= number_with_delimiter(row.total_tokens) %></td>
41
+ <% avg_latency = row.average_latency_ms %>
42
+ <td class="lct-num<%= ' lct-num-muted' if avg_latency.nil? %>"><%= avg_latency ? "#{number_with_delimiter(avg_latency.round)}ms" : "n/a" %></td>
43
+ <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
44
+ <td class="lct-num"><%= money(row.total_cost) %></td>
45
+ <td class="lct-num"><%= link_to "Calls", calls_path(calls_query_for_model(provider: row.provider, model: row.model)), class: "lct-page-link" %></td>
49
46
  </tr>
50
- </thead>
51
- <tbody>
52
- <% @rows.each do |row| %>
53
- <tr>
54
- <td><%= row.provider %></td>
55
- <td><code class="lct-code"><%= row.model %></code></td>
56
- <td class="lct-num"><%= number(row.calls) %></td>
57
- <td class="lct-num"><%= number(row.total_tokens) %></td>
58
- <td class="lct-num"><%= number(row.input_tokens) %></td>
59
- <td class="lct-num"><%= number(row.output_tokens) %></td>
60
- <td class="lct-num"><%= money(row.total_cost) %></td>
61
- <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
62
- <% average_latency_ms = row.average_latency_ms %>
63
- <td class="lct-num<%= ' lct-num-muted' if average_latency_ms.nil? %>"><%= average_latency_ms ? "#{number(average_latency_ms.round)}ms" : "n/a" %></td>
64
- <td><%= link_to "Calls", calls_path(calls_query_for_model(provider: row.provider, model: row.model)), class: "lct-button lct-button-secondary lct-button-compact" %></td>
65
- </tr>
66
- <% end %>
67
- </tbody>
68
- </table>
69
- </div>
47
+ <% end %>
48
+ </tbody>
49
+ </table>
70
50
  </section>
71
51
  <% end %>