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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +4 -1
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +15 -3
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/budget.rb +1 -2
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
- data/lib/llm_cost_tracker/configuration.rb +3 -20
- data/lib/llm_cost_tracker/doctor.rb +74 -8
- data/lib/llm_cost_tracker/event.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
- data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers.rb +0 -2
- data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
- data/lib/llm_cost_tracker/pricing.rb +1 -18
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -4
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/tracker.rb +1 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- metadata +14 -6
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- 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
|
-
<
|
|
3
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
<
|
|
27
|
-
<
|
|
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-
|
|
31
|
-
</
|
|
32
|
-
|
|
33
|
-
|
|
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-
|
|
37
|
-
</
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
</section>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
45
41
|
|
|
46
42
|
<section class="lct-panel">
|
|
47
|
-
<div class="lct-
|
|
48
|
-
<
|
|
49
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
101
|
+
<%= sortable_header("Value", "value") %>
|
|
102
|
+
<%= sortable_header("Calls", "calls", num: true) %>
|
|
121
103
|
<th class="lct-num">Share</th>
|
|
122
|
-
|
|
123
|
-
|
|
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-
|
|
140
|
-
<%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-
|
|
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
|
-
</
|
|
148
|
-
|
|
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
|
|
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
|
|
@@ -102,7 +102,7 @@ module LlmCostTracker
|
|
|
102
102
|
return nil if @finished || @recording
|
|
103
103
|
|
|
104
104
|
@recording = true
|
|
105
|
-
pricing_mode = Pricing.
|
|
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:
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
121
|
-
Pricing.
|
|
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
|
-
|
|
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)
|