llm_cost_tracker 0.8.0 → 0.9.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 +108 -0
- data/README.md +12 -5
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
- data/lib/llm_cost_tracker/budget.rb +4 -2
- data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +53 -1
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -3
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/event.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
- data/lib/llm_cost_tracker/ingestion.rb +48 -10
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
- data/lib/llm_cost_tracker/integrations/base.rb +22 -5
- data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
- data/lib/llm_cost_tracker/integrations.rb +19 -1
- data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
- data/lib/llm_cost_tracker/ledger/store.rb +14 -14
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
- data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
- data/lib/llm_cost_tracker/parsers/base.rb +5 -1
- data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
- data/lib/llm_cost_tracker/prices.json +110 -19
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
- data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
- data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
- data/lib/llm_cost_tracker/pricing.rb +47 -19
- data/lib/llm_cost_tracker/railtie.rb +6 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/retention.rb +15 -2
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
- data/lib/llm_cost_tracker/token_usage.rb +10 -2
- data/lib/llm_cost_tracker/tracker.rb +45 -18
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +9 -0
- data/lib/tasks/llm_cost_tracker.rake +25 -2
- metadata +36 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
2
|
+
<div class="lct-toolbar-head">
|
|
3
|
+
<h2 class="lct-section-title">Provider Invoice Reconciliation <span class="lct-badge lct-badge-warn">Experimental</span></h2>
|
|
4
|
+
<% if @last_imported_at %>
|
|
5
|
+
<p class="lct-state-copy">Last import: <%= @last_imported_at.utc.iso8601 %></p>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<p class="lct-state-copy">
|
|
9
|
+
Experimental in v0.9.0 — public API may change in v0.9.x based on feedback.
|
|
10
|
+
<%= link_to "Open an issue", "https://github.com/sergey-homenko/llm_cost_tracker/issues", target: "_blank", rel: "noopener" %>
|
|
11
|
+
if you use it.
|
|
12
|
+
</p>
|
|
13
|
+
</section>
|
|
14
|
+
|
|
15
|
+
<% if flash[:notice] %>
|
|
16
|
+
<section class="lct-panel"><p class="lct-state-copy"><%= flash[:notice] %></p></section>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if flash[:alert] %>
|
|
19
|
+
<section class="lct-panel"><p class="lct-state-copy"><%= flash[:alert] %></p></section>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<% if @configured_importers.any? %>
|
|
23
|
+
<section class="lct-panel">
|
|
24
|
+
<h3 class="lct-section-title">Trigger import</h3>
|
|
25
|
+
<% @configured_importers.each_key do |source| %>
|
|
26
|
+
<%= button_to "Re-import #{source}",
|
|
27
|
+
reconciliation_import_path(source: source),
|
|
28
|
+
method: :post,
|
|
29
|
+
class: "lct-button lct-button-secondary" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</section>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% if !@reconciliation_enabled %>
|
|
35
|
+
<section class="lct-panel lct-empty">
|
|
36
|
+
<h2 class="lct-state-title">Reconciliation disabled</h2>
|
|
37
|
+
<p class="lct-state-copy">
|
|
38
|
+
Provider invoice reconciliation is opt-in because it requires admin/org-level
|
|
39
|
+
provider API keys (OpenAI <code>sk-admin-…</code>, Anthropic admin keys, GCP
|
|
40
|
+
<code>billing.viewer</code>) — separate from the runtime inference key the
|
|
41
|
+
tracker uses. Enable explicitly in the initializer:
|
|
42
|
+
</p>
|
|
43
|
+
<pre class="lct-state-pre"><code>LlmCostTracker.configure do |config|
|
|
44
|
+
config.reconciliation_enabled = true
|
|
45
|
+
end</code></pre>
|
|
46
|
+
</section>
|
|
47
|
+
<% elsif !@reconciliation_installed %>
|
|
48
|
+
<section class="lct-panel lct-empty">
|
|
49
|
+
<h2 class="lct-state-title">Reconciliation not installed</h2>
|
|
50
|
+
<p class="lct-state-copy">
|
|
51
|
+
Run the optional migration to create the reconciliation tables:
|
|
52
|
+
</p>
|
|
53
|
+
<pre class="lct-state-pre"><code>bin/rails generate llm_cost_tracker:reconciliation
|
|
54
|
+
bin/rails db:migrate</code></pre>
|
|
55
|
+
</section>
|
|
56
|
+
<% elsif @diffs.empty? %>
|
|
57
|
+
<section class="lct-panel lct-empty">
|
|
58
|
+
<h2 class="lct-state-title">No invoices imported yet</h2>
|
|
59
|
+
<p class="lct-state-copy">
|
|
60
|
+
Reconciliation compares provider-side invoices against local cost. Once you import
|
|
61
|
+
invoice rows via <code>LlmCostTracker::Reconciliation.import</code>, they appear here.
|
|
62
|
+
</p>
|
|
63
|
+
</section>
|
|
64
|
+
<% else %>
|
|
65
|
+
<section class="lct-panel">
|
|
66
|
+
<h3 class="lct-section-title">Latest period per source / provider / currency</h3>
|
|
67
|
+
<table class="lct-table">
|
|
68
|
+
<thead>
|
|
69
|
+
<tr>
|
|
70
|
+
<th>Source</th>
|
|
71
|
+
<th>Provider</th>
|
|
72
|
+
<th>Currency</th>
|
|
73
|
+
<th>Period</th>
|
|
74
|
+
<th class="lct-num">Provider total</th>
|
|
75
|
+
<th class="lct-num">Local total</th>
|
|
76
|
+
<th class="lct-num">Delta</th>
|
|
77
|
+
<th class="lct-num">%</th>
|
|
78
|
+
<th>Status</th>
|
|
79
|
+
</tr>
|
|
80
|
+
</thead>
|
|
81
|
+
<tbody>
|
|
82
|
+
<% @diffs.each do |diff| %>
|
|
83
|
+
<tr>
|
|
84
|
+
<td><%= diff.source %></td>
|
|
85
|
+
<td><%= diff.provider %></td>
|
|
86
|
+
<td><%= diff.currency %></td>
|
|
87
|
+
<td><%= diff.period_start %> → <%= diff.period_end %></td>
|
|
88
|
+
<td class="lct-num"><%= money(diff.provider_total) %></td>
|
|
89
|
+
<td class="lct-num"><%= money(diff.local_total) %></td>
|
|
90
|
+
<td class="lct-num"><%= money(diff.delta_amount) %></td>
|
|
91
|
+
<td class="lct-num"><%= diff.delta_percent.nil? ? "—" : "#{diff.delta_percent}%" %></td>
|
|
92
|
+
<td>
|
|
93
|
+
<% if diff.aligned?(threshold_percent: @threshold) %>
|
|
94
|
+
<span class="lct-badge lct-badge-ok">Aligned</span>
|
|
95
|
+
<% else %>
|
|
96
|
+
<span class="lct-badge lct-badge-warn">Drift</span>
|
|
97
|
+
<% end %>
|
|
98
|
+
</td>
|
|
99
|
+
</tr>
|
|
100
|
+
<% end %>
|
|
101
|
+
</tbody>
|
|
102
|
+
</table>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<% @diffs.each do |diff| %>
|
|
106
|
+
<% next if diff.unmatched_provider_rows.empty? && diff.unmatched_local_calls.empty? && diff.non_cost_rows.empty? %>
|
|
107
|
+
|
|
108
|
+
<section class="lct-panel">
|
|
109
|
+
<h3 class="lct-section-title"><%= diff.source %> / <%= diff.provider %> / <%= diff.currency %> — drill down</h3>
|
|
110
|
+
|
|
111
|
+
<% if diff.unmatched_provider_rows.any? %>
|
|
112
|
+
<h4 class="lct-state-title">
|
|
113
|
+
Provider rows without a matching local call
|
|
114
|
+
<% if diff.unmatched_provider_rows_truncated? %>
|
|
115
|
+
<small>(showing <%= diff.unmatched_provider_rows.size %> of <%= diff.unmatched_provider_rows_total %>, ranked by billed amount)</small>
|
|
116
|
+
<% end %>
|
|
117
|
+
</h4>
|
|
118
|
+
<table class="lct-table">
|
|
119
|
+
<thead>
|
|
120
|
+
<tr><th>External ID</th><th>Match basis</th><th>Attribution</th><th class="lct-num">Billed</th></tr>
|
|
121
|
+
</thead>
|
|
122
|
+
<tbody>
|
|
123
|
+
<% diff.unmatched_provider_rows.each do |row| %>
|
|
124
|
+
<tr>
|
|
125
|
+
<td><%= row[:external_id] %></td>
|
|
126
|
+
<td><%= row[:match_basis] %></td>
|
|
127
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
128
|
+
<td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
|
|
129
|
+
</tr>
|
|
130
|
+
<% end %>
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
<% end %>
|
|
134
|
+
|
|
135
|
+
<% if diff.unmatched_local_calls.any? %>
|
|
136
|
+
<h4 class="lct-state-title">
|
|
137
|
+
Local calls no provider invoice can explain
|
|
138
|
+
<% if diff.unmatched_local_calls_truncated? %>
|
|
139
|
+
<small>(showing <%= diff.unmatched_local_calls.size %> of <%= diff.unmatched_local_calls_total %>, ranked by total cost)</small>
|
|
140
|
+
<% end %>
|
|
141
|
+
</h4>
|
|
142
|
+
<table class="lct-table">
|
|
143
|
+
<thead>
|
|
144
|
+
<tr><th>Attribution</th><th class="lct-num">Calls</th><th class="lct-num">Total cost</th></tr>
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody>
|
|
147
|
+
<% diff.unmatched_local_calls.each do |row| %>
|
|
148
|
+
<tr>
|
|
149
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
150
|
+
<td class="lct-num"><%= number(row[:count]) %></td>
|
|
151
|
+
<td class="lct-num"><%= money(row[:total_cost]) %></td>
|
|
152
|
+
</tr>
|
|
153
|
+
<% end %>
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
<% end %>
|
|
157
|
+
|
|
158
|
+
<% if diff.non_cost_rows.any? %>
|
|
159
|
+
<h4 class="lct-state-title">
|
|
160
|
+
Non-cost evidence (free quota, credits, adjustments)
|
|
161
|
+
<% if diff.non_cost_rows_truncated? %>
|
|
162
|
+
<small>(showing <%= diff.non_cost_rows.size %> of <%= diff.non_cost_rows_total %>, ranked by amount)</small>
|
|
163
|
+
<% end %>
|
|
164
|
+
</h4>
|
|
165
|
+
<table class="lct-table">
|
|
166
|
+
<thead>
|
|
167
|
+
<tr><th>Row type</th><th>Meter</th><th>Attribution</th><th class="lct-num">Amount</th></tr>
|
|
168
|
+
</thead>
|
|
169
|
+
<tbody>
|
|
170
|
+
<% diff.non_cost_rows.each do |row| %>
|
|
171
|
+
<tr>
|
|
172
|
+
<td><%= row[:row_type] %></td>
|
|
173
|
+
<td><%= row[:meter] %></td>
|
|
174
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
175
|
+
<td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
|
|
176
|
+
</tr>
|
|
177
|
+
<% end %>
|
|
178
|
+
</tbody>
|
|
179
|
+
</table>
|
|
180
|
+
<% end %>
|
|
181
|
+
</section>
|
|
182
|
+
<% end %>
|
|
183
|
+
<% end %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<% variant_class = local_assigns[:variant] == "budget" ? " lct-budget-fill" : "" %>
|
|
2
2
|
|
|
3
3
|
<div class="lct-bar-track" aria-hidden="true">
|
|
4
|
-
<div
|
|
4
|
+
<div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill<%= variant_class %>"></div>
|
|
5
5
|
</div>
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
%>
|
|
11
11
|
|
|
12
12
|
<form class="lct-filters" action="<%= path %>" method="get">
|
|
13
|
+
<% local_assigns.fetch(:hidden_fields, {}).each do |key, val| %>
|
|
14
|
+
<%= hidden_field_tag(key, val) %>
|
|
15
|
+
<% end %>
|
|
13
16
|
<div class="lct-filter-row">
|
|
14
17
|
<% if fields.include?(:from) %>
|
|
15
18
|
<div class="lct-field">
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<% else %>
|
|
6
6
|
<div class="lct-stack-track" aria-hidden="true">
|
|
7
7
|
<% visible_segments.each do |segment| %>
|
|
8
|
-
<span
|
|
8
|
+
<span data-lct-style="<%= inline_style("width: #{segment[:percent].round(2)}%") %>" class="lct-stack-fill <%= segment[:css_class] %>"></span>
|
|
9
9
|
<% end %>
|
|
10
10
|
</div>
|
|
11
11
|
|
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
<% 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>
|
|
9
|
+
|
|
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>
|
|
16
|
+
|
|
17
|
+
<% if @value_calls.zero? %>
|
|
18
|
+
<section class="lct-panel lct-empty">
|
|
19
|
+
<h2 class="lct-state-title">No calls tagged with <%= params[:key] %>=<%= @value %></h2>
|
|
20
|
+
<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
|
+
</section>
|
|
25
|
+
<% 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>
|
|
29
|
+
<p class="lct-stat-value"><%= money(@value_total_cost) %></p>
|
|
30
|
+
<p class="lct-stat-copy">Across <%= number(@value_calls) %> calls</p>
|
|
31
|
+
</article>
|
|
32
|
+
|
|
33
|
+
<article class="lct-stat">
|
|
34
|
+
<p class="lct-stat-label">Calls</p>
|
|
35
|
+
<p class="lct-stat-value"><%= number(@value_calls) %></p>
|
|
36
|
+
<p class="lct-stat-copy">Tagged with <code class="lct-code"><%= @value %></code></p>
|
|
37
|
+
</article>
|
|
38
|
+
|
|
39
|
+
<article class="lct-stat">
|
|
40
|
+
<p class="lct-stat-label">Avg cost / call</p>
|
|
41
|
+
<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>
|
|
45
|
+
|
|
46
|
+
<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" %>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
|
|
56
|
+
</section>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% else %>
|
|
1
59
|
<section class="lct-panel lct-toolbar">
|
|
2
60
|
<div class="lct-toolbar-head">
|
|
3
61
|
<div>
|
|
@@ -78,6 +136,7 @@
|
|
|
78
136
|
<% if row.value == "(untagged)" %>
|
|
79
137
|
<span class="lct-muted">n/a</span>
|
|
80
138
|
<% 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" %>
|
|
81
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" %>
|
|
82
141
|
<% end %>
|
|
83
142
|
</td>
|
|
@@ -88,3 +147,4 @@
|
|
|
88
147
|
</div>
|
|
89
148
|
</section>
|
|
90
149
|
<% end %>
|
|
150
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -4,9 +4,10 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
4
4
|
root "dashboard#index"
|
|
5
5
|
resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
|
|
6
6
|
resources :models, only: :index
|
|
7
|
-
|
|
8
|
-
get "tags/:key", to: "tags#show", as: :tag, format: false
|
|
7
|
+
resources :tags, only: %i[index show], param: :key, format: false
|
|
9
8
|
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
9
|
+
get "reconciliation", to: "reconciliation#index", as: :reconciliation
|
|
10
|
+
post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
|
|
10
11
|
|
|
11
12
|
get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
|
|
12
13
|
to: "assets#stylesheet", as: :stylesheet
|
|
@@ -6,6 +6,37 @@ require_relative "../errors"
|
|
|
6
6
|
|
|
7
7
|
module LlmCostTracker
|
|
8
8
|
module Billing
|
|
9
|
+
RATE_BASES = %i[
|
|
10
|
+
per_million_tokens
|
|
11
|
+
per_million_characters
|
|
12
|
+
per_request
|
|
13
|
+
per_1k_requests
|
|
14
|
+
per_session
|
|
15
|
+
per_hour
|
|
16
|
+
per_gb_day
|
|
17
|
+
per_image
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
RATE_BASIS_QUANTITIES = {
|
|
21
|
+
per_million_tokens: 1_000_000,
|
|
22
|
+
per_million_characters: 1_000_000,
|
|
23
|
+
per_request: 1,
|
|
24
|
+
per_1k_requests: 1_000,
|
|
25
|
+
per_session: 1,
|
|
26
|
+
per_hour: 1,
|
|
27
|
+
per_gb_day: 1,
|
|
28
|
+
per_image: 1
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
DEFAULT_RATE_BASIS_BY_UNIT = {
|
|
32
|
+
token: :per_million_tokens,
|
|
33
|
+
character: :per_million_characters,
|
|
34
|
+
request: :per_request,
|
|
35
|
+
session: :per_session,
|
|
36
|
+
hour: :per_hour,
|
|
37
|
+
image: :per_image
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
9
40
|
module Components
|
|
10
41
|
Component = Data.define(
|
|
11
42
|
:key,
|
|
@@ -16,7 +47,8 @@ module LlmCostTracker
|
|
|
16
47
|
:unit,
|
|
17
48
|
:category,
|
|
18
49
|
:token_key,
|
|
19
|
-
:cost_key
|
|
50
|
+
:cost_key,
|
|
51
|
+
:rate_basis
|
|
20
52
|
)
|
|
21
53
|
|
|
22
54
|
REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
|
|
@@ -32,16 +64,26 @@ module LlmCostTracker
|
|
|
32
64
|
missing = REQUIRED_FIELDS - attributes.keys
|
|
33
65
|
raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
|
|
34
66
|
|
|
67
|
+
unit = attributes.fetch(:unit).to_sym
|
|
68
|
+
rate_basis = attributes[:rate_basis]&.to_sym || Billing::DEFAULT_RATE_BASIS_BY_UNIT[unit]
|
|
69
|
+
if rate_basis.nil?
|
|
70
|
+
raise Error, "components.yml entry needs rate_basis for unit #{unit.inspect}: #{attributes.inspect}"
|
|
71
|
+
end
|
|
72
|
+
unless Billing::RATE_BASES.include?(rate_basis)
|
|
73
|
+
raise Error, "components.yml entry has unknown rate_basis #{rate_basis.inspect}: #{attributes.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
35
76
|
Component.new(
|
|
36
77
|
key: attributes.fetch(:key).to_sym,
|
|
37
78
|
kind: attributes.fetch(:kind).to_sym,
|
|
38
79
|
direction: attributes.fetch(:direction).to_sym,
|
|
39
80
|
modality: attributes.fetch(:modality).to_sym,
|
|
40
81
|
cache_state: attributes.fetch(:cache_state).to_sym,
|
|
41
|
-
unit:
|
|
82
|
+
unit: unit,
|
|
42
83
|
category: attributes.fetch(:category).to_sym,
|
|
43
84
|
token_key: attributes[:token_key]&.to_sym,
|
|
44
|
-
cost_key: attributes[:cost_key]&.to_sym
|
|
85
|
+
cost_key: attributes[:cost_key]&.to_sym,
|
|
86
|
+
rate_basis: rate_basis
|
|
45
87
|
)
|
|
46
88
|
end
|
|
47
89
|
|
|
@@ -68,6 +68,26 @@
|
|
|
68
68
|
token_key: audio_output_tokens
|
|
69
69
|
cost_key: audio_output_cost
|
|
70
70
|
|
|
71
|
+
- key: image_input
|
|
72
|
+
kind: image_token
|
|
73
|
+
direction: input
|
|
74
|
+
modality: image
|
|
75
|
+
cache_state: none
|
|
76
|
+
unit: token
|
|
77
|
+
category: token
|
|
78
|
+
token_key: image_input_tokens
|
|
79
|
+
cost_key: image_input_cost
|
|
80
|
+
|
|
81
|
+
- key: image_output
|
|
82
|
+
kind: image_token
|
|
83
|
+
direction: output
|
|
84
|
+
modality: image
|
|
85
|
+
cache_state: none
|
|
86
|
+
unit: token
|
|
87
|
+
category: token
|
|
88
|
+
token_key: image_output_tokens
|
|
89
|
+
cost_key: image_output_cost
|
|
90
|
+
|
|
71
91
|
- key: web_search_request
|
|
72
92
|
kind: web_search_request
|
|
73
93
|
direction: neither
|
|
@@ -75,6 +95,34 @@
|
|
|
75
95
|
cache_state: none
|
|
76
96
|
unit: request
|
|
77
97
|
category: tool
|
|
98
|
+
rate_basis: per_1k_requests
|
|
99
|
+
|
|
100
|
+
- key: web_search_preview_request_reasoning
|
|
101
|
+
kind: web_search_preview_request_reasoning
|
|
102
|
+
direction: neither
|
|
103
|
+
modality: text
|
|
104
|
+
cache_state: none
|
|
105
|
+
unit: request
|
|
106
|
+
category: tool
|
|
107
|
+
rate_basis: per_1k_requests
|
|
108
|
+
|
|
109
|
+
- key: web_search_preview_request_non_reasoning
|
|
110
|
+
kind: web_search_preview_request_non_reasoning
|
|
111
|
+
direction: neither
|
|
112
|
+
modality: text
|
|
113
|
+
cache_state: none
|
|
114
|
+
unit: request
|
|
115
|
+
category: tool
|
|
116
|
+
rate_basis: per_1k_requests
|
|
117
|
+
|
|
118
|
+
- key: web_fetch_request
|
|
119
|
+
kind: web_fetch_request
|
|
120
|
+
direction: neither
|
|
121
|
+
modality: text
|
|
122
|
+
cache_state: none
|
|
123
|
+
unit: request
|
|
124
|
+
category: tool
|
|
125
|
+
rate_basis: per_1k_requests
|
|
78
126
|
|
|
79
127
|
- key: file_search_call
|
|
80
128
|
kind: file_search_call
|
|
@@ -83,6 +131,7 @@
|
|
|
83
131
|
cache_state: none
|
|
84
132
|
unit: request
|
|
85
133
|
category: tool
|
|
134
|
+
rate_basis: per_1k_requests
|
|
86
135
|
|
|
87
136
|
- key: container_session
|
|
88
137
|
kind: container_session
|
|
@@ -91,6 +140,7 @@
|
|
|
91
140
|
cache_state: none
|
|
92
141
|
unit: session
|
|
93
142
|
category: runtime
|
|
143
|
+
rate_basis: per_session
|
|
94
144
|
|
|
95
145
|
- key: code_execution_request
|
|
96
146
|
kind: code_execution_request
|
|
@@ -99,6 +149,7 @@
|
|
|
99
149
|
cache_state: none
|
|
100
150
|
unit: request
|
|
101
151
|
category: runtime
|
|
152
|
+
rate_basis: per_1k_requests
|
|
102
153
|
|
|
103
154
|
- key: code_execution_hour
|
|
104
155
|
kind: code_execution_hour
|
|
@@ -107,6 +158,7 @@
|
|
|
107
158
|
cache_state: none
|
|
108
159
|
unit: hour
|
|
109
160
|
category: runtime
|
|
161
|
+
rate_basis: per_hour
|
|
110
162
|
|
|
111
163
|
- key: grounding_request
|
|
112
164
|
kind: grounding_request
|
|
@@ -115,3 +167,22 @@
|
|
|
115
167
|
cache_state: none
|
|
116
168
|
unit: request
|
|
117
169
|
category: tool
|
|
170
|
+
rate_basis: per_1k_requests
|
|
171
|
+
|
|
172
|
+
- key: text_to_speech_character
|
|
173
|
+
kind: text_to_speech_character
|
|
174
|
+
direction: output
|
|
175
|
+
modality: audio
|
|
176
|
+
cache_state: none
|
|
177
|
+
unit: character
|
|
178
|
+
category: tool
|
|
179
|
+
rate_basis: per_million_characters
|
|
180
|
+
|
|
181
|
+
- key: mcp_call
|
|
182
|
+
kind: mcp_call
|
|
183
|
+
direction: neither
|
|
184
|
+
modality: text
|
|
185
|
+
cache_state: none
|
|
186
|
+
unit: request
|
|
187
|
+
category: tool
|
|
188
|
+
rate_basis: per_request
|
|
@@ -19,8 +19,11 @@ module LlmCostTracker
|
|
|
19
19
|
|
|
20
20
|
budgets.each do |budget_type, budget|
|
|
21
21
|
total = totals.fetch(budget_type)
|
|
22
|
+
next unless total >= budget
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
raise BudgetExceededError.new(**budget_payload(
|
|
25
|
+
budget_type: budget_type, total: total, budget: budget, last_event: nil
|
|
26
|
+
))
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
@@ -87,7 +90,6 @@ module LlmCostTracker
|
|
|
87
90
|
|
|
88
91
|
def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
|
|
89
92
|
return false unless config.on_budget_exceeded
|
|
90
|
-
return true unless config.budget_exceeded_behavior == :notify
|
|
91
93
|
return true unless last_event&.total_cost
|
|
92
94
|
return true if budget_type == :per_call
|
|
93
95
|
|