llm_cost_tracker 0.10.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +4 -1
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
  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 +15 -3
  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/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
  17. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  25. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  26. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  27. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  28. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  33. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  34. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  36. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  37. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  38. data/config/routes.rb +1 -0
  39. data/lib/llm_cost_tracker/budget.rb +1 -2
  40. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
  43. data/lib/llm_cost_tracker/configuration.rb +3 -20
  44. data/lib/llm_cost_tracker/doctor.rb +74 -8
  45. data/lib/llm_cost_tracker/event.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
  49. data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
  50. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
  51. data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
  52. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
  53. data/lib/llm_cost_tracker/ledger.rb +13 -0
  54. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
  55. data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
  56. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
  57. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
  58. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  59. data/lib/llm_cost_tracker/parsers.rb +0 -2
  60. data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
  61. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  62. data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
  63. data/lib/llm_cost_tracker/pricing.rb +1 -18
  64. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  65. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  66. data/lib/llm_cost_tracker/railtie.rb +0 -4
  67. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  68. data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
  69. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
  70. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  71. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  72. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  73. data/lib/llm_cost_tracker/tracker.rb +1 -1
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. metadata +14 -6
  76. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  77. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  78. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  79. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
@@ -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(@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>
29
+ <p class="lct-stat-foot">Across <%= number(@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>
35
33
  <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>
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(@breakdown.tagged_calls) %> tagged call<%= "s" unless @breakdown.tagged_calls == 1 %> · <%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %> coverage · <%= number(@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(@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(@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(@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(@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) %>
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>
112
+ <td><code class="lct-code-id"><%= row.value %></code></td>
131
113
  <td class="lct-num"><%= number(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,6 +6,7 @@ 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 "pricing", to: "pricing#index", as: :pricing
9
10
  get "reconciliation", to: "reconciliation#index", as: :reconciliation
10
11
  post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
11
12
 
@@ -112,8 +112,7 @@ module LlmCostTracker
112
112
 
113
113
  def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
114
114
  return false unless config.on_budget_exceeded
115
- return true unless last_event&.total_cost
116
- return true if budget_type == :per_call
115
+ return true if !last_event&.total_cost || budget_type == :per_call
117
116
 
118
117
  total - last_event.total_cost < budget
119
118
  end
@@ -4,7 +4,7 @@ require "active_support/core_ext/object/blank"
4
4
  require "json"
5
5
 
6
6
  module LlmCostTracker
7
- module Parsers
7
+ module Capture
8
8
  module SSE
9
9
  DONE_MARKER = "[DONE]"
10
10
 
@@ -102,7 +102,7 @@ module LlmCostTracker
102
102
  return nil if @finished || @recording
103
103
 
104
104
  @recording = true
105
- pricing_mode = Pricing.normalize_mode(@pricing_mode)
105
+ pricing_mode = Pricing::Mode.normalize(@pricing_mode)
106
106
  {
107
107
  events: @events.dup,
108
108
  overflowed: @overflowed,
@@ -129,7 +129,7 @@ module LlmCostTracker
129
129
  Tracker.record(
130
130
  event: event,
131
131
  latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
132
- pricing_mode: merge_pricing_modes(event.pricing_mode, snapshot[:pricing_mode]),
132
+ pricing_mode: Pricing::Mode.merge(event.pricing_mode, snapshot[:pricing_mode]),
133
133
  metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
134
134
  context_tags: snapshot[:context_tags]
135
135
  ) { save_succeeded = true }
@@ -141,20 +141,6 @@ module LlmCostTracker
141
141
  end
142
142
  end
143
143
 
144
- HOST_DERIVED_MODE_TOKENS = %i[data_residency].freeze
145
- private_constant :HOST_DERIVED_MODE_TOKENS
146
-
147
- def merge_pricing_modes(provider_mode, request_mode)
148
- return Pricing.normalize_mode(request_mode) if provider_mode.to_s.strip.empty?
149
-
150
- provider_tokens = Pricing::Mode.tokenize(provider_mode) - Pricing::STANDARD_MODE_VALUES
151
- request_host_tokens = Pricing::Mode.tokenize(request_mode || "") & HOST_DERIVED_MODE_TOKENS
152
- combined = provider_tokens | request_host_tokens
153
- return nil if combined.empty?
154
-
155
- Pricing.normalize_mode(combined.join("_"))
156
- end
157
-
158
144
  def capture_dimensions(pricing_mode)
159
145
  batch = @batch.nil? ? Event.batch_from_pricing_mode?(pricing_mode).presence : @batch
160
146
  {
@@ -74,11 +74,7 @@ module LlmCostTracker
74
74
  end
75
75
 
76
76
  def capture(event)
77
- raw_payload = event.try(:deep_to_h) || event.try(:to_h)
78
- raw_payload ||= %i[type id model usage response message].each_with_object({}) do |key, attributes|
79
- value = event.try(key)
80
- attributes[key] = value unless value.nil?
81
- end
77
+ raw_payload = event.try(:deep_to_h) || event.try(:to_h) || {}
82
78
  payload = normalize(raw_payload)
83
79
  type = event.try(:type) || payload["type"]
84
80
  @collector.event(payload, type: type&.to_s)
@@ -17,7 +17,8 @@ module LlmCostTracker
17
17
  INGESTION_MODES = %i[inline async].freeze
18
18
  SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
19
19
  log_level prices_file max_tag_count max_tag_value_bytesize
20
- ingestion_pool_size].freeze
20
+ ingestion_pool_size auto_enable_stream_usage cache_rollups
21
+ reconciliation_enabled].freeze
21
22
  ENUM_ATTRIBUTES = {
22
23
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
23
24
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn],
@@ -35,10 +36,7 @@ module LlmCostTracker
35
36
  :redacted_tag_keys,
36
37
  :unknown_pricing_behavior,
37
38
  :openai_compatible_providers,
38
- :reconciliation_importers,
39
- :reconciliation_enabled,
40
- :auto_enable_stream_usage,
41
- :cache_rollups
39
+ :reconciliation_importers
42
40
  )
43
41
 
44
42
  def initialize
@@ -68,21 +66,6 @@ module LlmCostTracker
68
66
  @finalized = false
69
67
  end
70
68
 
71
- def cache_rollups=(value)
72
- ensure_mutable!
73
- @cache_rollups = value
74
- end
75
-
76
- def reconciliation_enabled=(value)
77
- ensure_mutable!
78
- @reconciliation_enabled = value
79
- end
80
-
81
- def auto_enable_stream_usage=(value)
82
- ensure_mutable!
83
- @auto_enable_stream_usage = value
84
- end
85
-
86
69
  def reconciliation_importers=(importers)
87
70
  ensure_mutable!
88
71
  raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
@@ -16,20 +16,82 @@ module LlmCostTracker
16
16
  autoload :InvoiceReconciliationCheck, "llm_cost_tracker/doctor/invoice_reconciliation_check"
17
17
  autoload :CaptureVerifier, "llm_cost_tracker/doctor/capture_verifier"
18
18
 
19
+ STATUS_GLYPHS = { ok: "✓", warn: "!", error: "x" }.freeze
20
+ STATUS_COLORS = { ok: 32, warn: 33, error: 31 }.freeze
21
+
22
+ SECTIONS = ["Setup", "Schema", "Data integrity", "Operations"].freeze
23
+
24
+ SECTION_FOR_CHECK = {
25
+ "configuration" => "Setup",
26
+ "capture" => "Setup",
27
+ "active_record" => "Schema",
28
+ "llm_cost_tracker_calls" => "Schema",
29
+ "llm_cost_tracker_calls columns" => "Schema",
30
+ "call line items" => "Schema",
31
+ "call tags" => "Schema",
32
+ "provider invoices" => "Schema",
33
+ "provider invoice imports" => "Schema",
34
+ "cost drift" => "Data integrity",
35
+ "pricing snapshot drift" => "Data integrity",
36
+ "pricing snapshot audit" => "Data integrity",
37
+ "cost status" => "Data integrity",
38
+ "invoice reconciliation" => "Data integrity",
39
+ "call rollups" => "Operations",
40
+ "inline ingestion" => "Operations",
41
+ "async ingestion" => "Operations",
42
+ "prices" => "Operations",
43
+ "tracked calls" => "Operations"
44
+ }.freeze
45
+
46
+ private_constant :STATUS_GLYPHS, :STATUS_COLORS, :SECTIONS, :SECTION_FOR_CHECK
47
+
19
48
  class << self
20
49
  def call
21
50
  new.checks
22
51
  end
23
52
 
24
- def report(checks = call)
25
- (["LLM Cost Tracker doctor"] + checks.map do |check|
26
- "[#{check.status}] #{check.name}: #{check.message}"
27
- end).join("\n")
53
+ def report(checks = call, color: $stdout.tty?)
54
+ name_width = checks.map { |c| c.name.length }.max.to_i
55
+
56
+ lines = [bold("LLM Cost Tracker doctor", color), ""]
57
+ each_section(checks) do |section, members|
58
+ lines << bold(section, color)
59
+ members.each do |check|
60
+ status = paint_status("[#{STATUS_GLYPHS.fetch(check.status, check.status)}]", check.status, color)
61
+ lines << " #{status} #{"#{check.name}:".ljust(name_width + 1)} #{check.message}"
62
+ end
63
+ lines << ""
64
+ end
65
+ lines.pop if lines.last == ""
66
+ lines.join("\n")
28
67
  end
29
68
 
30
69
  def healthy?(checks = call)
31
70
  checks.none? { |check| check.status == :error }
32
71
  end
72
+
73
+ private
74
+
75
+ def each_section(checks)
76
+ SECTIONS.each do |section|
77
+ members = checks.select { |c| (SECTION_FOR_CHECK[c.name] || "Setup") == section }
78
+ next if members.empty?
79
+
80
+ yield section, members
81
+ end
82
+ end
83
+
84
+ def paint_status(text, status, color)
85
+ return text unless color && STATUS_COLORS.key?(status)
86
+
87
+ "\e[#{STATUS_COLORS[status]}m#{text}\e[0m"
88
+ end
89
+
90
+ def bold(text, color)
91
+ return text unless color
92
+
93
+ "\e[1m#{text}\e[0m"
94
+ end
33
95
  end
34
96
 
35
97
  def checks
@@ -40,10 +102,7 @@ module LlmCostTracker
40
102
  active_record_check,
41
103
  table_check,
42
104
  column_check,
43
- SchemaCheck.new(name: "call line items", schema: Ledger::Schema::CallLineItems,
44
- table: "llm_cost_tracker_call_line_items").call,
45
- SchemaCheck.new(name: "call tags", schema: Ledger::Schema::CallTags,
46
- table: "llm_cost_tracker_call_tags").call,
105
+ *dependent_core_schema_checks,
47
106
  *reconciliation_schema_checks,
48
107
  CostDriftCheck.new.call,
49
108
  PricingSnapshotDriftCheck.new.call,
@@ -59,6 +118,13 @@ module LlmCostTracker
59
118
 
60
119
  private
61
120
 
121
+ def dependent_core_schema_checks
122
+ Ledger::Schema::CORE_SCHEMAS.reject { |schema, _| schema == Ledger::Schema::Calls }.map do |schema, table|
123
+ SchemaCheck.new(name: table.delete_prefix("llm_cost_tracker_").tr("_", " "),
124
+ schema: schema, table: table).call
125
+ end
126
+ end
127
+
62
128
  def reconciliation_schema_checks
63
129
  return [] unless LlmCostTracker.reconciliation_enabled?
64
130
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "pricing"
3
+ require_relative "pricing/mode"
4
4
  require_relative "billing/line_item"
5
5
 
6
6
  module LlmCostTracker
@@ -30,7 +30,7 @@ module LlmCostTracker
30
30
  end
31
31
 
32
32
  def self.build(**attributes)
33
- pricing_mode = Pricing.normalize_mode(attributes[:pricing_mode])
33
+ pricing_mode = Pricing::Mode.normalize(attributes[:pricing_mode])
34
34
  token_usage = attributes.fetch(:token_usage)
35
35
  batch = attributes[:batch].nil? ? batch_from_pricing_mode?(pricing_mode) : attributes[:batch]
36
36
  line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items], token_usage)
@@ -8,12 +8,16 @@ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migr
8
8
  NEW_INDEX = %i[period period_start currency provider].freeze
9
9
 
10
10
  def up
11
+ return unless table_exists?(TABLE)
12
+
11
13
  add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
12
14
  add_unique_index NEW_INDEX
13
15
  remove_index TABLE, column: OLD_INDEX, unique: true, if_exists: true
14
16
  end
15
17
 
16
18
  def down
19
+ return unless table_exists?(TABLE)
20
+
17
21
  add_unique_index OLD_INDEX
18
22
  remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
19
23
  remove_column TABLE, :provider if column_exists?(TABLE, :provider)
@@ -8,6 +8,8 @@ class UpgradeLlmCostTrackerProviderInvoiceImportsProvider < ActiveRecord::Migrat
8
8
  NEW_INDEX = %i[source provider started_at].freeze
9
9
 
10
10
  def up
11
+ return unless table_exists?(TABLE)
12
+
11
13
  add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
12
14
  if postgresql?
13
15
  remove_index TABLE, column: OLD_INDEX, algorithm: :concurrently, if_exists: true
@@ -19,6 +21,8 @@ class UpgradeLlmCostTrackerProviderInvoiceImportsProvider < ActiveRecord::Migrat
19
21
  end
20
22
 
21
23
  def down
24
+ return unless table_exists?(TABLE)
25
+
22
26
  remove_index TABLE, column: NEW_INDEX, if_exists: true
23
27
  add_index TABLE, OLD_INDEX, if_not_exists: true
24
28
  remove_column TABLE, :provider if column_exists?(TABLE, :provider)
@@ -7,12 +7,14 @@ class UpgradeLlmCostTrackerProviderInvoicesMetadataIndex < ActiveRecord::Migrati
7
7
 
8
8
  def up
9
9
  return unless postgresql?
10
+ return unless table_exists?(TABLE)
10
11
 
11
12
  add_index TABLE, :metadata, using: :gin, algorithm: :concurrently, if_not_exists: true
12
13
  end
13
14
 
14
15
  def down
15
16
  return unless postgresql?
17
+ return unless table_exists?(TABLE)
16
18
 
17
19
  remove_index TABLE, column: :metadata, if_exists: true
18
20
  end
@@ -38,7 +38,7 @@ module LlmCostTracker
38
38
  provider: payload.fetch(:provider),
39
39
  model: payload.fetch(:model),
40
40
  token_usage: token_usage,
41
- pricing_mode: Pricing.normalize_mode(payload[:pricing_mode]),
41
+ pricing_mode: Pricing::Mode.normalize(payload[:pricing_mode]),
42
42
  cost: cost,
43
43
  tags: payload.fetch(:tags),
44
44
  latency_ms: payload[:latency_ms],
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "../billing/line_item"
5
+ require_relative "../providers/anthropic/server_tools"
5
6
  require_relative "../providers/anthropic/tier_classification"
6
7
 
7
8
  module LlmCostTracker
@@ -64,27 +65,18 @@ module LlmCostTracker
64
65
  server_tool_use = object_value(usage, :server_tool_use)
65
66
  return [] unless server_tool_use
66
67
 
67
- [
68
- line_item_for_server_tool(server_tool_use, :web_search_request, :web_search_requests,
69
- "usage.server_tool_use.web_search_requests"),
70
- line_item_for_server_tool(server_tool_use, :web_fetch_request, :web_fetch_requests,
71
- "usage.server_tool_use.web_fetch_requests"),
72
- line_item_for_server_tool(server_tool_use, :code_execution_request, :code_execution_requests,
73
- "usage.server_tool_use.code_execution_requests")
74
- ].compact
75
- end
68
+ Providers::Anthropic::ServerTools::LINE_ITEMS.filter_map do |component_key, count_key|
69
+ quantity = object_value(server_tool_use, count_key).to_i
70
+ next if quantity.zero?
76
71
 
77
- def line_item_for_server_tool(server_tool_use, component_key, count_key, provider_field)
78
- quantity = object_value(server_tool_use, count_key).to_i
79
- return nil if quantity.zero?
80
-
81
- Billing::LineItem.build(
82
- component_key: component_key,
83
- quantity: quantity,
84
- cost_status: Billing::CostStatus::UNKNOWN,
85
- pricing_basis: :provider_usage,
86
- provider_field: provider_field
87
- )
72
+ Billing::LineItem.build(
73
+ component_key: component_key,
74
+ quantity: quantity,
75
+ cost_status: Billing::CostStatus::UNKNOWN,
76
+ pricing_basis: :provider_usage,
77
+ provider_field: "usage.server_tool_use.#{count_key}"
78
+ )
79
+ end
88
80
  end
89
81
 
90
82
  def token_usage(usage:, input_tokens:, output_tokens:)
@@ -117,8 +109,8 @@ module LlmCostTracker
117
109
  service_tier = nil if tier.standard_equivalent_tier?(service_tier)
118
110
 
119
111
  modes = [
120
- Pricing.normalize_mode(object_value(usage, :speed) || request[:speed]),
121
- Pricing.normalize_mode(service_tier)
112
+ Pricing::Mode.normalize(object_value(usage, :speed) || request[:speed]),
113
+ Pricing::Mode.normalize(service_tier)
122
114
  ]
123
115
  geo = inference_geo(request: request, usage: usage).to_s.downcase
124
116
  modes << "data_residency" if tier.data_residency_geo?(geo)
@@ -141,20 +133,20 @@ module LlmCostTracker
141
133
  stream = yield
142
134
  track_stream(stream, collector: collector)
143
135
  end
136
+
137
+ def wrap_blocking_call(args, kwargs)
138
+ request = request_params(args, kwargs)
139
+ enforce_budget!(request: request)
140
+ started_at = LlmCostTracker::Timing.now_monotonic
141
+ message = yield
142
+ record_message(message, request: request, latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at))
143
+ message
144
+ end
144
145
  end
145
146
 
146
147
  module MessagesPatch
147
148
  def create(*args, **kwargs)
148
- request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
149
- LlmCostTracker::Integrations::Anthropic.enforce_budget!(request: request)
150
- started_at = LlmCostTracker::Timing.now_monotonic
151
- message = super
152
- LlmCostTracker::Integrations::Anthropic.record_message(
153
- message,
154
- request: request,
155
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at)
156
- )
157
- message
149
+ LlmCostTracker::Integrations::Anthropic.wrap_blocking_call(args, kwargs) { super }
158
150
  end
159
151
 
160
152
  def stream(*args, **kwargs)