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
@@ -12,156 +12,155 @@ end %>
12
12
  { label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
13
13
  end %>
14
14
  <% end %>
15
-
16
- <section class="lct-panel">
17
- <nav class="lct-breadcrumb" aria-label="Breadcrumb">
18
- <%= link_to "Calls", calls_path, class: "lct-breadcrumb-link" %>
19
- <span class="lct-breadcrumb-sep" aria-hidden="true">›</span>
20
- <span class="lct-breadcrumb-current">#<%= @call.id %></span>
21
- </nav>
22
- <div class="lct-call-hero">
23
- <div>
24
- <h2 class="lct-section-title lct-call-title">
25
- <code class="lct-code"><%= @call.model %></code>
26
- </h2>
27
- <p class="lct-call-subtitle">
28
- <code class="lct-code"><%= @call.provider %></code>
29
- <span>·</span>
30
- <span><%= format_date(@call.tracked_at) %></span>
31
- </p>
32
- </div>
33
-
34
- <div class="lct-call-summary">
35
- <div class="lct-call-summary-item">
36
- <span class="lct-call-summary-label">Pricing</span>
37
- <strong><%= pricing_status(@call) %></strong>
38
- </div>
39
- <div class="lct-call-summary-item">
40
- <span class="lct-call-summary-label">Total cost</span>
41
- <strong><%= optional_money(@call.total_cost) %></strong>
42
- </div>
43
- <div class="lct-call-summary-item">
44
- <span class="lct-call-summary-label">Total tokens</span>
45
- <strong><%= number(@call.total_tokens) %></strong>
46
- </div>
47
- <div class="lct-call-summary-item">
48
- <span class="lct-call-summary-label">Latency</span>
49
- <strong><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></strong>
50
- </div>
51
- </div>
15
+ <% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
16
+
17
+ <p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
18
+ <h2 class="lct-page-title"><code class="lct-code-id"><%= @call.model %></code> <span class="lct-page-title-meta">#<%= @call.id %></span></h2>
19
+ <p class="lct-page-subtitle">
20
+ <span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= @call.provider %>"></span><%= @call.provider %></span>
21
+ <span class="lct-page-subtitle-sep">·</span>
22
+ <span><%= format_date(@call.tracked_at) %></span>
23
+ </p>
24
+
25
+ <div class="lct-stat-grid">
26
+ <div class="lct-stat">
27
+ <div class="lct-stat-head"><p class="lct-stat-label">Pricing</p></div>
28
+ <p class="lct-stat-value-sm"><%= pricing_status(@call) %></p>
29
+ </div>
30
+ <div class="lct-stat">
31
+ <div class="lct-stat-head"><p class="lct-stat-label">Total cost</p></div>
32
+ <p class="lct-stat-value<%= ' lct-num-muted' if @call.total_cost.nil? %>"><%= optional_money(@call.total_cost) %></p>
33
+ </div>
34
+ <div class="lct-stat">
35
+ <div class="lct-stat-head"><p class="lct-stat-label">Total tokens</p></div>
36
+ <p class="lct-stat-value"><%= number(@call.total_tokens) %></p>
37
+ </div>
38
+ <div class="lct-stat">
39
+ <div class="lct-stat-head"><p class="lct-stat-label">Latency</p></div>
40
+ <p class="lct-stat-value<%= ' lct-num-muted' if @call.latency_ms.nil? %>"><%= @call.latency_ms ? number(@call.latency_ms) : "n/a" %><% if @call.latency_ms %><span class="lct-stat-unit">ms</span><% end %></p>
52
41
  </div>
42
+ </div>
53
43
 
54
- <div class="lct-call-breakdown-grid">
55
- <section class="lct-call-breakdown">
56
- <h3 class="lct-call-breakdown-title">Token Mix</h3>
44
+ <div class="lct-grid-2">
45
+ <section class="lct-panel">
46
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Token mix</h2></div>
47
+ <div class="lct-panel-body">
57
48
  <%= render "llm_cost_tracker/shared/metric_stack", segments: token_segments, empty_message: "No token data for this call." %>
58
- </section>
49
+ </div>
50
+ </section>
59
51
 
60
- <section class="lct-call-breakdown">
61
- <h3 class="lct-call-breakdown-title">Cost Mix</h3>
52
+ <section class="lct-panel">
53
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Cost mix</h2></div>
54
+ <div class="lct-panel-body">
62
55
  <% if @call.total_cost.nil? %>
63
- <p class="lct-call-breakdown-empty">Pricing not available for this call.</p>
56
+ <p class="lct-stack-empty">Pricing not available for this call.</p>
64
57
  <% else %>
65
58
  <%= render "llm_cost_tracker/shared/metric_stack", segments: cost_segments, empty_message: "No cost breakdown for this call." %>
66
59
  <% end %>
67
- </section>
68
- </div>
69
-
70
- <div class="lct-detail-grid">
71
- <dl class="lct-dl">
72
- <dt>Cost Status</dt>
73
- <dd><%= @call.cost_status.presence || "n/a" %></dd>
74
-
75
- <dt>Latency</dt>
76
- <dd><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></dd>
60
+ </div>
61
+ </section>
62
+ </div>
77
63
 
64
+ <section class="lct-panel">
65
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Provider context</h2></div>
66
+ <dl class="lct-meta-strip">
67
+ <div class="lct-meta-strip-item">
78
68
  <dt>Batch</dt>
79
69
  <dd><%= @call.batch? ? "yes" : "no" %></dd>
80
-
70
+ </div>
71
+ <% if @call.has_attribute?(:stream) %>
72
+ <div class="lct-meta-strip-item">
73
+ <dt>Stream</dt>
74
+ <dd><%= @call.stream? ? "yes" : "no" %></dd>
75
+ </div>
76
+ <% end %>
77
+ <% if @call.has_attribute?(:usage_source) && @call.usage_source.present? %>
78
+ <div class="lct-meta-strip-item">
79
+ <dt>Usage source</dt>
80
+ <dd><code class="lct-code-id"><%= @call.usage_source %></code></dd>
81
+ </div>
82
+ <% end %>
83
+ <div class="lct-meta-strip-item">
81
84
  <dt>Response ID</dt>
82
- <dd><%= @call.provider_response_id.presence || "n/a" %></dd>
83
-
85
+ <dd><%= @call.provider_response_id.present? ? content_tag(:code, @call.provider_response_id, class: "lct-code-id") : "n/a" %></dd>
86
+ </div>
87
+ <div class="lct-meta-strip-item">
84
88
  <dt>Project ID</dt>
85
- <dd><%= @call.provider_project_id.present? ? LlmCostTracker::Masking.mask_value(:provider_project_id, @call.provider_project_id) : "n/a" %></dd>
86
-
89
+ <dd><%= @call.provider_project_id.present? ? content_tag(:code, LlmCostTracker::Masking.mask_value(:provider_project_id, @call.provider_project_id), class: "lct-code-id") : "n/a" %></dd>
90
+ </div>
91
+ <div class="lct-meta-strip-item">
87
92
  <dt>API Key ID</dt>
88
- <dd><%= @call.provider_api_key_id.present? ? LlmCostTracker::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id) : "n/a" %></dd>
89
-
93
+ <dd><%= @call.provider_api_key_id.present? ? content_tag(:code, LlmCostTracker::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id), class: "lct-code-id") : "n/a" %></dd>
94
+ </div>
95
+ <div class="lct-meta-strip-item">
90
96
  <dt>Workspace ID</dt>
91
- <dd><%= @call.provider_workspace_id.present? ? LlmCostTracker::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id) : "n/a" %></dd>
92
- </dl>
93
-
94
- <dl class="lct-dl">
95
- <% priced_components.each do |component| %>
96
- <dt><%= component.fetch(:label).titleize %> Tokens</dt>
97
- <dd><%= number(@call[component.fetch(:token_key)]) %></dd>
98
- <% end %>
99
-
100
- <dt>Total Tokens</dt>
101
- <dd><%= number(@call.total_tokens) %></dd>
102
- </dl>
103
-
104
- <dl class="lct-dl">
105
- <% priced_components.each do |component| %>
106
- <dt><%= component.fetch(:label).titleize %> Cost</dt>
107
- <dd><%= optional_money(line_item_costs_by_component[component.fetch(:price_key)]) %></dd>
108
- <% end %>
97
+ <dd><%= @call.provider_workspace_id.present? ? content_tag(:code, LlmCostTracker::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id), class: "lct-code-id") : "n/a" %></dd>
98
+ </div>
99
+ </dl>
100
+ </section>
109
101
 
110
- <dt>Total Cost</dt>
111
- <dd><%= optional_money(@call.total_cost) %></dd>
112
- </dl>
102
+ <section class="lct-panel">
103
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Tags</h2></div>
104
+ <div class="lct-panel-body">
105
+ <% if @call.parsed_tags.empty? %>
106
+ <p class="lct-stack-empty">(untagged)</p>
107
+ <% else %>
108
+ <span class="lct-tag-chips">
109
+ <% @call.parsed_tags.each do |k, v| %>
110
+ <span class="lct-tag-chip"><span class="lct-tag-chip-key"><%= k %></span>=<%= v %></span>
111
+ <% end %>
112
+ </span>
113
+ <% end %>
113
114
  </div>
114
115
  </section>
115
116
 
116
- <% service_line_items = @call.line_items.where.not(unit: "token").order(:position).to_a %>
117
117
  <% if service_line_items.any? %>
118
118
  <section class="lct-panel">
119
- <h2 class="lct-section-title">Service Charges</h2>
120
- <div class="lct-table-wrap">
121
- <table class="lct-table lct-table-compact">
122
- <thead>
119
+ <div class="lct-panel-head"><h2 class="lct-panel-title">Service charges</h2></div>
120
+ <table class="lct-tbl">
121
+ <thead>
122
+ <tr>
123
+ <th>Component</th>
124
+ <th>Unit</th>
125
+ <th class="lct-num">Quantity</th>
126
+ <th class="lct-num">Rate</th>
127
+ <th class="lct-num">Cost</th>
128
+ <th>Status</th>
129
+ </tr>
130
+ </thead>
131
+ <tbody>
132
+ <% service_line_items.each do |line_item| %>
123
133
  <tr>
124
- <th>Component</th>
125
- <th>Unit</th>
126
- <th class="lct-num">Quantity</th>
127
- <th class="lct-num">Rate</th>
128
- <th class="lct-num">Cost</th>
129
- <th>Status</th>
134
+ <td><code class="lct-code-id"><%= line_item.kind %></code></td>
135
+ <td><%= line_item.unit %></td>
136
+ <td class="lct-num"><%= line_item.quantity %></td>
137
+ <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
138
+ <% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
139
+ <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
140
+ <td><%= line_item.cost_status %></td>
130
141
  </tr>
131
- </thead>
132
- <tbody>
133
- <% service_line_items.each do |line_item| %>
134
- <tr>
135
- <td><code class="lct-code"><%= line_item.kind %></code></td>
136
- <td><%= line_item.unit %></td>
137
- <td class="lct-num"><%= line_item.quantity %></td>
138
- <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
139
- <% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
140
- <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
141
- <td><%= line_item.cost_status %></td>
142
- </tr>
143
- <% end %>
144
- </tbody>
145
- </table>
146
- </div>
142
+ <% end %>
143
+ </tbody>
144
+ </table>
147
145
  </section>
148
146
  <% end %>
149
147
 
150
148
  <% if @call.pricing_snapshot.present? %>
151
- <section class="lct-panel">
152
- <h2 class="lct-section-title">Pricing Snapshot</h2>
149
+ <details class="lct-panel lct-disclose">
150
+ <summary class="lct-panel-head lct-disclose-summary">
151
+ <h2 class="lct-panel-title">Pricing snapshot</h2>
152
+ <span class="lct-disclose-hint">show JSON</span>
153
+ </summary>
153
154
  <pre class="lct-pre"><%= safe_json(@call.pricing_snapshot) %></pre>
154
- </section>
155
+ </details>
155
156
  <% end %>
156
157
 
157
- <section class="lct-panel">
158
- <h2 class="lct-section-title">Tags</h2>
159
- <pre class="lct-pre"><%= safe_json(@call.parsed_tags) %></pre>
160
- </section>
161
-
162
158
  <% if @call.has_attribute?("metadata") %>
163
- <section class="lct-panel">
164
- <h2 class="lct-section-title">Metadata</h2>
159
+ <details class="lct-panel lct-disclose">
160
+ <summary class="lct-panel-head lct-disclose-summary">
161
+ <h2 class="lct-panel-title">Metadata</h2>
162
+ <span class="lct-disclose-hint">show JSON</span>
163
+ </summary>
165
164
  <pre class="lct-pre"><%= safe_json(LlmCostTracker::Masking.mask_hash(masked_metadata_hash(@call.read_attribute("metadata")))) %></pre>
166
- </section>
165
+ </details>
167
166
  <% end %>
@@ -1,202 +1,163 @@
1
- <% overview_filter_scope = current_query(from: @from_date.iso8601, to: @to_date.iso8601) %>
1
+ <div class="lct-filter-row">
2
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: root_path %>
3
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: root_path %>
4
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: root_path %>
2
5
 
3
- <section class="lct-panel lct-toolbar">
4
- <%= render "llm_cost_tracker/shared/filters",
5
- path: root_path,
6
- filter_scope: overview_filter_scope,
7
- defaults: { from: @from_date.iso8601, to: @to_date.iso8601 } %>
6
+ <% if params[:provider].present? || params[:model].present? %>
7
+ <%= link_to "× Clear filters", root_path(current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
8
+ <% end %>
8
9
 
9
- <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: root_path %>
10
- </section>
10
+ <span class="lct-filter-row-meta">
11
+ <%= number(@stats.total_calls) %> call<%= "s" unless @stats.total_calls.to_i == 1 %> · <%= money(@stats.total_cost) %>
12
+ </span>
13
+ </div>
11
14
 
12
15
  <% if @stats.total_calls.to_i.zero? %>
13
16
  <section class="lct-panel lct-empty">
14
17
  <h2 class="lct-state-title">No LLM calls yet</h2>
15
18
  <p class="lct-state-copy">Tracked requests will appear here after your application records its first LLM API call.</p>
16
- <div class="lct-state-actions">
17
- <%= link_to "Calls", calls_path, class: "lct-button lct-button-secondary" %>
18
- </div>
19
19
  </section>
20
20
  <% else %>
21
21
  <% if @stats.unknown_pricing_count.to_i.positive? %>
22
- <aside class="lct-banner lct-banner-warning" role="status">
23
- <div class="lct-banner-body">
24
- <p class="lct-banner-title">
25
- <%= number(@stats.unknown_pricing_count) %> call<%= "s" unless @stats.unknown_pricing_count.to_i == 1 %> missing pricing
26
- <span class="lct-banner-muted">· <%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of the slice</span>
27
- </p>
28
- <p class="lct-banner-copy">Totals undercount until every model has a known price. Update <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code> to fix.</p>
29
- </div>
30
- <%= link_to "Fix now →", data_quality_path, class: "lct-button lct-button-secondary" %>
31
- </aside>
22
+ <div class="lct-alert lct-alert-warn">
23
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
24
+ <span>
25
+ <strong><%= number(@stats.unknown_pricing_count) %> call<%= "s" unless @stats.unknown_pricing_count.to_i == 1 %> with incomplete pricing</strong>
26
+ · <%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of slice
27
+ — update <code>pricing_overrides</code> or <code>prices_file</code>.
28
+ </span>
29
+ <%= link_to "Fix now →", data_quality_path, class: "lct-alert-action" %>
30
+ </div>
32
31
  <% end %>
33
32
 
34
33
  <% if @spend_anomaly %>
35
- <aside class="lct-banner lct-banner-danger" role="status">
36
- <div class="lct-banner-body">
37
- <p class="lct-banner-title">
38
- Spend anomaly detected
39
- <span class="lct-banner-muted">· <code class="lct-code"><%= @spend_anomaly.fetch(:model) %></code> on <%= @spend_anomaly.fetch(:day).strftime("%b %-d") %></span>
40
- </p>
41
- <p class="lct-banner-copy">
42
- <% if @spend_anomaly.fetch(:ratio) %>
43
- <%= number_with_precision(@spend_anomaly.fetch(:ratio), precision: 1) %>× its prior 7-day average in this slice
44
- <% else %>
45
- <%= money(@spend_anomaly.fetch(:latest_spend)) %> after seven quiet days in this slice
46
- <% end %>
47
- </p>
48
- </div>
49
- <%= link_to "Calls",
34
+ <div class="lct-alert lct-alert-danger">
35
+ <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>
36
+ <span>
37
+ <strong>Spend anomaly detected</strong>
38
+ · <code><%= @spend_anomaly.fetch(:model) %></code> on <%= @spend_anomaly.fetch(:day).strftime("%b %-d") %>
39
+ <% if @spend_anomaly.fetch(:ratio) %>
40
+ <%= number_with_precision(@spend_anomaly.fetch(:ratio), precision: 1) %>× its prior 7-day average.
41
+ <% else %>
42
+ <%= money(@spend_anomaly.fetch(:latest_spend)) %> after seven quiet days.
43
+ <% end %>
44
+ </span>
45
+ <%= link_to "View calls →",
50
46
  calls_path(current_query(provider: @spend_anomaly.fetch(:provider), model: @spend_anomaly.fetch(:model), from: @to_date.iso8601, to: @to_date.iso8601, page: nil, per: nil, format: nil)),
51
- class: "lct-button lct-button-secondary" %>
52
- </aside>
47
+ class: "lct-alert-action" %>
48
+ </div>
53
49
  <% end %>
54
50
 
55
- <section class="lct-hero">
56
- <article class="lct-panel lct-hero-primary">
57
- <div>
51
+ <div class="lct-stat-grid">
52
+ <div class="lct-stat">
53
+ <div class="lct-stat-head">
58
54
  <p class="lct-stat-label">Total spend</p>
59
- <p class="lct-hero-value"><%= money(@stats.total_cost) %></p>
60
55
  <% badge = delta_badge(@stats.cost_delta_percent) %>
61
- <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
56
+ <span class="<%= badge[:css_class] %>"><%= badge[:text] %></span>
62
57
  </div>
63
- </article>
64
-
65
- <div class="lct-hero-side">
66
- <div class="lct-stat-grid">
67
- <article class="lct-stat">
68
- <p class="lct-stat-label">Avg cost / call</p>
69
- <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
70
- </article>
71
-
72
- <article class="lct-stat">
73
- <p class="lct-stat-label">Calls</p>
74
- <p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
75
- <% badge = delta_badge(@stats.calls_delta_percent, mode: :neutral) %>
76
- <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
77
- </article>
78
-
79
- <% if @stats.average_latency_ms %>
80
- <article class="lct-stat">
81
- <p class="lct-stat-label">Avg latency</p>
82
- <p class="lct-stat-value"><%= number(@stats.average_latency_ms.round) %>ms</p>
83
- </article>
84
- <% end %>
58
+ <p class="lct-stat-value"><%= money(@stats.total_cost) %></p>
59
+ <p class="lct-stat-foot">Avg <%= money(@stats.average_cost_per_call) %> / call</p>
60
+ </div>
61
+ <div class="lct-stat lct-stat-ok">
62
+ <div class="lct-stat-head">
63
+ <p class="lct-stat-label">Calls</p>
64
+ <% badge = delta_badge(@stats.calls_delta_percent, mode: :neutral) %>
65
+ <span class="<%= badge[:css_class] %>"><%= badge[:text] %></span>
66
+ </div>
67
+ <p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
68
+ </div>
69
+ <% if @stats.average_latency_ms %>
70
+ <div class="lct-stat lct-stat-ok">
71
+ <div class="lct-stat-head"><p class="lct-stat-label">Avg latency</p></div>
72
+ <p class="lct-stat-value"><%= number(@stats.average_latency_ms.round) %><span class="lct-stat-unit">ms</span></p>
73
+ </div>
74
+ <% else %>
75
+ <div class="lct-stat">
76
+ <div class="lct-stat-head"><p class="lct-stat-label">Avg latency</p></div>
77
+ <p class="lct-stat-value lct-num-muted">n/a</p>
78
+ </div>
79
+ <% end %>
80
+ <div class="lct-stat <%= @stats.unknown_pricing_count.to_i.positive? ? 'lct-stat-warn' : 'lct-stat-ok' %>">
81
+ <div class="lct-stat-head"><p class="lct-stat-label">Incomplete pricing</p></div>
82
+ <p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
83
+ <p class="lct-stat-foot"><%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of slice</p>
84
+ </div>
85
+ </div>
85
86
 
87
+ <% if @monthly_budget_status %>
88
+ <% budget = @monthly_budget_status %>
89
+ <section class="lct-panel">
90
+ <div class="lct-panel-head">
91
+ <h2 class="lct-panel-title">Monthly budget</h2>
92
+ <span class="lct-panel-meta">
93
+ <span><%= money(budget[:spent]) %> of <%= money(budget[:budget]) %> · <%= percent(budget[:percent_used]) %></span>
94
+ </span>
95
+ </div>
96
+ <div class="lct-panel-body">
97
+ <div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= budget[:progress_percent].round %>">
98
+ <div data-lct-style="<%= inline_style("width: #{budget[:progress_percent]}%") %>" class="lct-budget-fill <%= budget[:fill_modifier] %>"></div>
99
+ <% if budget[:projected_spent].positive? %>
100
+ <span data-lct-style="<%= inline_style("left: calc(#{budget[:projected_marker_percent]}% - 1px)") %>" class="lct-budget-marker" aria-hidden="true"></span>
101
+ <% end %>
102
+ </div>
103
+ <p class="lct-budget-projection">
104
+ <span>Projected <strong><%= money(budget[:projected_spent]) %></strong> by <%= budget[:projection_end_label] %></span>
105
+ <span class="lct-budget-projection-status <%= budget[:projected_delta_status_class] %>">
106
+ <%= money(budget[:projected_delta_amount]) %> <%= budget[:projected_delta_direction] %> budget
107
+ </span>
108
+ </p>
86
109
  </div>
110
+ </section>
111
+ <% end %>
87
112
 
88
- <% if @monthly_budget_status %>
89
- <% budget = @monthly_budget_status %>
90
- <section class="lct-panel lct-panel-tight">
91
- <div class="lct-section-head">
92
- <div>
93
- <h2 class="lct-section-title">Monthly Budget</h2>
94
- <p class="lct-section-copy">Current-month spend across <strong>all</strong> calls.</p>
95
- </div>
96
- </div>
97
- <div class="lct-budget">
98
- <div class="lct-budget-head">
99
- <span>
100
- <span class="lct-budget-spent"><%= money(budget[:spent]) %></span>
101
- <span class="lct-budget-of"> of <%= money(budget[:budget]) %></span>
102
- </span>
103
- <span class="lct-budget-percent"><%= percent(budget[:percent_used]) %></span>
104
- </div>
105
- <div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= budget[:progress_percent].round %>">
106
- <div data-lct-style="<%= inline_style("width: #{budget[:progress_percent]}%") %>" class="lct-budget-fill <%= budget[:fill_modifier] %>"></div>
107
- <% if budget[:projected_spent].positive? %>
108
- <span data-lct-style="<%= inline_style("left: calc(#{budget[:projected_marker_percent]}% - 1px)") %>" class="lct-budget-marker" aria-hidden="true"></span>
109
- <% end %>
110
- </div>
111
- <p class="lct-budget-projection">
112
- <span>Projected <strong><%= money(budget[:projected_spent]) %></strong> by <%= budget[:projection_end_label] %></span>
113
- <span class="lct-budget-projection-status <%= budget[:projected_delta_status_class] %>">
114
- <%= money(budget[:projected_delta_amount]) %> <%= budget[:projected_delta_direction] %> budget
115
- </span>
116
- </p>
117
- <p class="lct-budget-meta">Soft limit: blocking is not atomic under concurrency.</p>
118
- </div>
119
- </section>
120
- <% end %>
113
+ <section class="lct-panel">
114
+ <div class="lct-panel-head">
115
+ <h2 class="lct-panel-title">Daily spend</h2>
116
+ <span class="lct-panel-meta">
117
+ <span class="lct-legend"><span class="lct-legend-dot lct-legend-dot-current"></span>current</span>
118
+ <% if @comparison_series.any? %><span class="lct-legend"><span class="lct-legend-dot lct-legend-dot-prior"></span>prior <%= number(@comparison_series.size) %>d</span><% end %>
119
+ </span>
121
120
  </div>
121
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @time_series, comparison_series: @comparison_series %>
122
122
  </section>
123
123
 
124
- <section class="lct-grid lct-two-col">
124
+ <div class="lct-grid-2">
125
125
  <section class="lct-panel">
126
- <div class="lct-section-head">
127
- <div>
128
- <h2 class="lct-section-title">Daily Spend</h2>
129
- <p class="lct-section-copy">Current slice vs. previous <%= number(@comparison_series.size) %>-day slice.</p>
130
- </div>
126
+ <div class="lct-panel-head">
127
+ <h2 class="lct-panel-title">Top models</h2>
128
+ <span class="lct-panel-meta"><%= link_to "View all →", models_path(current_query) %></span>
131
129
  </div>
132
- <%= render "llm_cost_tracker/shared/spend_chart", series: @time_series, comparison_series: @comparison_series %>
130
+ <table class="lct-tbl">
131
+ <thead><tr><th>Model</th><th class="lct-num">Calls</th><th class="lct-num">Cost</th></tr></thead>
132
+ <tbody>
133
+ <% @top_models.first(5).each do |row| %>
134
+ <tr>
135
+ <td><span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= row.provider %>"></span><%= link_to calls_path(calls_query_for_model(provider: row.provider, model: row.model)), class: "lct-code-id" do %><%= row.model %><% end %></span></td>
136
+ <td class="lct-num"><%= number(row.calls) %></td>
137
+ <td class="lct-num"><%= money(row.total_cost) %></td>
138
+ </tr>
139
+ <% end %>
140
+ </tbody>
141
+ </table>
133
142
  </section>
134
143
 
135
144
  <% if @providers.any? %>
136
145
  <section class="lct-panel">
137
- <div class="lct-section-head">
138
- <div>
139
- <h2 class="lct-section-title">By Provider</h2>
140
- <p class="lct-section-copy">Spend share across the selected slice.</p>
141
- </div>
142
- </div>
143
- <table class="lct-table lct-table-compact">
144
- <thead>
145
- <tr>
146
- <th>Provider</th>
147
- <th class="lct-num">Calls</th>
148
- <th class="lct-num">Spend</th>
149
- <th class="lct-num">Share</th>
150
- <th></th>
151
- </tr>
152
- </thead>
146
+ <div class="lct-panel-head"><h2 class="lct-panel-title">By provider</h2></div>
147
+ <table class="lct-tbl">
148
+ <thead><tr><th>Provider</th><th class="lct-num">Calls</th><th class="lct-num">Cost</th><th class="lct-num">Share</th></tr></thead>
153
149
  <tbody>
154
150
  <% @providers.each do |row| %>
155
151
  <tr>
156
- <td><%= row.provider %></td>
152
+ <td><span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= row.provider %>"></span><%= row.provider %></span></td>
157
153
  <td class="lct-num"><%= number(row.calls) %></td>
158
154
  <td class="lct-num"><%= money(row.total_cost) %></td>
159
155
  <td class="lct-num"><%= percent(row.share_percent) %></td>
160
- <td><%= link_to "Calls", calls_path(current_query(provider: row.provider, page: nil, per: nil, format: nil)), class: "lct-button lct-button-secondary lct-button-compact" %></td>
161
156
  </tr>
162
157
  <% end %>
163
158
  </tbody>
164
159
  </table>
165
160
  </section>
166
161
  <% end %>
167
- </section>
168
-
169
- <section class="lct-panel">
170
- <div class="lct-section-head">
171
- <div>
172
- <h2 class="lct-section-title">Top Models</h2>
173
- <p class="lct-section-copy">The heaviest contributors in the current slice.</p>
174
- </div>
175
- <%= link_to "View all models", models_path(current_query), class: "lct-button lct-button-secondary lct-button-compact" %>
176
- </div>
177
- <table class="lct-table lct-table-compact">
178
- <thead>
179
- <tr>
180
- <th>Provider</th>
181
- <th>Model</th>
182
- <th class="lct-num">Calls</th>
183
- <th class="lct-num">Spend</th>
184
- <th class="lct-num">Avg cost / call</th>
185
- <th></th>
186
- </tr>
187
- </thead>
188
- <tbody>
189
- <% @top_models.each do |row| %>
190
- <tr>
191
- <td><%= row.provider %></td>
192
- <td><code class="lct-code"><%= row.model %></code></td>
193
- <td class="lct-num"><%= number(row.calls) %></td>
194
- <td class="lct-num"><%= money(row.total_cost) %></td>
195
- <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
196
- <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>
197
- </tr>
198
- <% end %>
199
- </tbody>
200
- </table>
201
- </section>
162
+ </div>
202
163
  <% end %>