llm_cost_tracker 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +7 -4
- data/app/assets/llm_cost_tracker/application.css +8 -7
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
- data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
- data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
- data/config/routes.rb +2 -3
- data/lib/llm_cost_tracker/budget.rb +24 -26
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/capture/sse.rb +1 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -44
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +5 -69
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +8 -18
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
- data/lib/llm_cost_tracker/parsers.rb +139 -26
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -278
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -3
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +1 -1
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +81 -55
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
- data/lib/llm_cost_tracker/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<%= link_to "× Clear filters", tags_path(current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
|
|
8
8
|
<% end %>
|
|
9
9
|
|
|
10
|
-
<span class="lct-filter-row-meta"><%=
|
|
10
|
+
<span class="lct-filter-row-meta"><%= number_with_delimiter(@rows.size) %> tag key<%= "s" unless @rows.size == 1 %></span>
|
|
11
11
|
</div>
|
|
12
12
|
|
|
13
13
|
<% if @rows.empty? %>
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
<% @rows.each do |row| %>
|
|
31
31
|
<tr>
|
|
32
32
|
<td><code class="lct-code-id"><%= row.key %></code></td>
|
|
33
|
-
<td class="lct-num"><%=
|
|
34
|
-
<td class="lct-num"><%=
|
|
33
|
+
<td class="lct-num"><%= number_with_delimiter(row.calls_count) %></td>
|
|
34
|
+
<td class="lct-num"><%= number_with_delimiter(row.distinct_values) %></td>
|
|
35
35
|
<td class="lct-num"><%= link_to "Breakdown", tag_path(row.key, current_query), class: "lct-page-link" %></td>
|
|
36
36
|
</tr>
|
|
37
37
|
<% end %>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
|
|
14
14
|
<% end %>
|
|
15
15
|
|
|
16
|
-
<span class="lct-filter-row-meta"><%=
|
|
16
|
+
<span class="lct-filter-row-meta"><%= number_with_delimiter(@value_calls) %> call<%= "s" unless @value_calls == 1 %> · <%= money(@value_total_cost) %></span>
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
19
|
<% if @value_calls.zero? %>
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
<div class="lct-stat">
|
|
27
27
|
<div class="lct-stat-head"><p class="lct-stat-label">Total cost</p></div>
|
|
28
28
|
<p class="lct-stat-value"><%= money(@value_total_cost) %></p>
|
|
29
|
-
<p class="lct-stat-foot">Across <%=
|
|
29
|
+
<p class="lct-stat-foot">Across <%= number_with_delimiter(@value_calls) %> calls</p>
|
|
30
30
|
</div>
|
|
31
31
|
<div class="lct-stat">
|
|
32
32
|
<div class="lct-stat-head"><p class="lct-stat-label">Calls</p></div>
|
|
33
|
-
<p class="lct-stat-value"><%=
|
|
33
|
+
<p class="lct-stat-value"><%= number_with_delimiter(@value_calls) %></p>
|
|
34
34
|
<p class="lct-stat-foot">Tagged with <code class="lct-code-id"><%= @value %></code></p>
|
|
35
35
|
</div>
|
|
36
36
|
<div class="lct-stat">
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
<%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
|
|
63
63
|
<% end %>
|
|
64
64
|
|
|
65
|
-
<span class="lct-filter-row-meta"><%=
|
|
65
|
+
<span class="lct-filter-row-meta"><%= number_with_delimiter(@breakdown.tagged_calls) %> tagged call<%= "s" unless @breakdown.tagged_calls == 1 %> · <%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %> coverage · <%= number_with_delimiter(@breakdown.distinct_values) %> distinct value<%= "s" unless @breakdown.distinct_values == 1 %></span>
|
|
66
66
|
</div>
|
|
67
67
|
|
|
68
68
|
<% if @breakdown.rows.empty? %>
|
|
@@ -74,23 +74,23 @@
|
|
|
74
74
|
<div class="lct-stat-grid">
|
|
75
75
|
<div class="lct-stat">
|
|
76
76
|
<div class="lct-stat-head"><p class="lct-stat-label">Tagged calls</p></div>
|
|
77
|
-
<p class="lct-stat-value"><%=
|
|
77
|
+
<p class="lct-stat-value"><%= number_with_delimiter(@breakdown.tagged_calls) %></p>
|
|
78
78
|
<p class="lct-stat-foot">Rows that include <code class="lct-code-id"><%= params[:key] %></code></p>
|
|
79
79
|
</div>
|
|
80
80
|
<div class="lct-stat">
|
|
81
81
|
<div class="lct-stat-head"><p class="lct-stat-label">Coverage</p></div>
|
|
82
82
|
<p class="lct-stat-value"><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></p>
|
|
83
|
-
<p class="lct-stat-foot"><%=
|
|
83
|
+
<p class="lct-stat-foot"><%= number_with_delimiter(@breakdown.total_calls) %> total calls in this slice</p>
|
|
84
84
|
</div>
|
|
85
85
|
<div class="lct-stat">
|
|
86
86
|
<div class="lct-stat-head"><p class="lct-stat-label">Distinct values</p></div>
|
|
87
|
-
<p class="lct-stat-value"><%=
|
|
87
|
+
<p class="lct-stat-value"><%= number_with_delimiter(@breakdown.distinct_values) %></p>
|
|
88
88
|
</div>
|
|
89
89
|
</div>
|
|
90
90
|
|
|
91
91
|
<% if @breakdown.distinct_values > @breakdown.rows.size %>
|
|
92
92
|
<div class="lct-alert lct-alert-info">
|
|
93
|
-
<span>Showing top <%=
|
|
93
|
+
<span>Showing top <%= number_with_delimiter(@breakdown.limit) %> values by spend.</span>
|
|
94
94
|
</div>
|
|
95
95
|
<% end %>
|
|
96
96
|
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
<%= sortable_header("Value", "value") %>
|
|
102
102
|
<%= sortable_header("Calls", "calls", num: true) %>
|
|
103
103
|
<th class="lct-num">Share</th>
|
|
104
|
-
<%= sortable_header("Total cost", "cost", num: true) %>
|
|
104
|
+
<%= sortable_header("Total cost", "cost", num: true, default: true) %>
|
|
105
105
|
<%= sortable_header("Avg cost / call", "avg_cost", num: true) %>
|
|
106
106
|
<th></th>
|
|
107
107
|
</tr>
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
<% @breakdown.rows.each do |row| %>
|
|
111
111
|
<tr>
|
|
112
112
|
<td><code class="lct-code-id"><%= row.value %></code></td>
|
|
113
|
-
<td class="lct-num"><%=
|
|
113
|
+
<td class="lct-num"><%= number_with_delimiter(row.calls) %></td>
|
|
114
114
|
<td class="lct-num"><%= percent(row.share_percent) %></td>
|
|
115
115
|
<td class="lct-num"><%= money(row.total_cost) %></td>
|
|
116
116
|
<td class="lct-num"><%= money(row.average_cost_per_call) %></td>
|
data/config/routes.rb
CHANGED
|
@@ -7,9 +7,8 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
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
9
|
get "pricing", to: "pricing#index", as: :pricing
|
|
10
|
-
get "reconciliation", to: "reconciliation#index", as: :reconciliation
|
|
11
|
-
post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
|
|
12
10
|
|
|
13
11
|
get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
|
|
14
|
-
to: "assets#stylesheet",
|
|
12
|
+
to: "assets#stylesheet",
|
|
13
|
+
as: :stylesheet
|
|
15
14
|
end
|
|
@@ -2,31 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal"
|
|
4
4
|
|
|
5
|
-
require_relative "logging"
|
|
6
5
|
require_relative "ledger"
|
|
7
6
|
require_relative "pricing/estimator"
|
|
8
7
|
|
|
9
8
|
module LlmCostTracker
|
|
10
|
-
|
|
9
|
+
module Budget
|
|
11
10
|
BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
|
|
12
11
|
|
|
13
12
|
class << self
|
|
14
|
-
def enforce!(provider: nil, model: nil, request: nil)
|
|
13
|
+
def enforce!(provider: nil, model: nil, request: nil, estimate: nil, force: false)
|
|
15
14
|
config = LlmCostTracker.configuration
|
|
16
|
-
return unless config.
|
|
15
|
+
return unless config.enabled
|
|
16
|
+
return unless force || config.budget_exceeded_behavior == :block_requests
|
|
17
17
|
|
|
18
|
-
estimate
|
|
18
|
+
estimate ||= estimate_cost(provider: provider, model: model, request: request)
|
|
19
19
|
raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
totals = totals_for(budgets.keys, time: Time.now.utc)
|
|
25
|
-
|
|
26
|
-
budgets.each do |budget_type, budget|
|
|
27
|
-
total = totals.fetch(budget_type) + estimate
|
|
28
|
-
next unless total >= budget
|
|
29
|
-
|
|
21
|
+
check_windowed({ monthly: config.monthly_budget, daily: config.daily_budget }.compact,
|
|
22
|
+
time: Time.now.utc,
|
|
23
|
+
estimate: estimate) do |budget_type, total, budget|
|
|
30
24
|
raise BudgetExceededError.new(**budget_payload(
|
|
31
25
|
budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
|
|
32
26
|
))
|
|
@@ -38,13 +32,9 @@ module LlmCostTracker
|
|
|
38
32
|
return unless event.total_cost
|
|
39
33
|
|
|
40
34
|
check_per_call_budget(event, config)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
budgets.each do |budget_type, budget|
|
|
45
|
-
total = totals.fetch(budget_type)
|
|
46
|
-
|
|
47
|
-
handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event) if total >= budget
|
|
35
|
+
check_windowed({ daily: config.daily_budget, monthly: config.monthly_budget }.compact,
|
|
36
|
+
time: event.tracked_at) do |budget_type, total, budget|
|
|
37
|
+
handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event)
|
|
48
38
|
end
|
|
49
39
|
end
|
|
50
40
|
|
|
@@ -74,14 +64,22 @@ module LlmCostTracker
|
|
|
74
64
|
handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
|
|
75
65
|
end
|
|
76
66
|
|
|
67
|
+
def check_windowed(budgets, time:, estimate: BigDecimal("0"))
|
|
68
|
+
return if budgets.empty?
|
|
69
|
+
|
|
70
|
+
totals = totals_for(budgets.keys, time: time)
|
|
71
|
+
budgets.each do |budget_type, budget|
|
|
72
|
+
total = totals.fetch(budget_type) + estimate
|
|
73
|
+
yield(budget_type, total, budget) if total >= budget
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
77
|
def totals_for(budget_types, time:)
|
|
78
78
|
return {} if budget_types.empty?
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
period_totals = LlmCostTracker::Ledger::Period::Totals.call(
|
|
82
|
-
|
|
83
|
-
totals[budget_type] = period_totals[period] if period_totals.key?(period)
|
|
84
|
-
end
|
|
80
|
+
period_for = budget_types.to_h { |type| [type, BUDGET_TYPE_TO_PERIOD.fetch(type)] }
|
|
81
|
+
period_totals = LlmCostTracker::Ledger::Period::Totals.call(period_for.values, time: time)
|
|
82
|
+
period_for.transform_values { |period| period_totals.fetch(period) }
|
|
85
83
|
end
|
|
86
84
|
|
|
87
85
|
def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/deep_dup"
|
|
4
|
+
require "active_support/core_ext/object/try"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Capture
|
|
8
|
+
module SdkPayload
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def normalize(value)
|
|
12
|
+
case value
|
|
13
|
+
when Hash
|
|
14
|
+
value.each_with_object({}) { |(key, nested), out| out[key.to_s] = normalize(nested) }
|
|
15
|
+
when Array
|
|
16
|
+
value.map { |nested| normalize(nested) }
|
|
17
|
+
when Symbol
|
|
18
|
+
value.to_s
|
|
19
|
+
when NilClass
|
|
20
|
+
nil
|
|
21
|
+
else
|
|
22
|
+
converted = container_for(value)
|
|
23
|
+
converted ? normalize(converted) : value.deep_dup
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def container_for(value)
|
|
28
|
+
value.try(:deep_to_h) || value.try(:to_h)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -4,8 +4,7 @@ require "active_support/core_ext/object/blank"
|
|
|
4
4
|
require "active_support/core_ext/object/deep_dup"
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "../pricing/mode"
|
|
7
|
+
require_relative "sse"
|
|
9
8
|
require_relative "../timing"
|
|
10
9
|
|
|
11
10
|
module LlmCostTracker
|
|
@@ -13,9 +12,17 @@ module LlmCostTracker
|
|
|
13
12
|
class StreamCollector
|
|
14
13
|
attr_reader :provider
|
|
15
14
|
|
|
16
|
-
def initialize(provider:,
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
def initialize(provider:,
|
|
16
|
+
model:,
|
|
17
|
+
latency_ms: nil,
|
|
18
|
+
provider_response_id: nil,
|
|
19
|
+
provider_project_id: nil,
|
|
20
|
+
provider_api_key_id: nil,
|
|
21
|
+
provider_workspace_id: nil,
|
|
22
|
+
pricing_mode: nil,
|
|
23
|
+
metadata: {},
|
|
24
|
+
context_tags: nil,
|
|
25
|
+
request: nil)
|
|
19
26
|
@provider = provider.to_s
|
|
20
27
|
@model = model
|
|
21
28
|
@latency_ms = latency_ms
|
|
@@ -23,7 +30,6 @@ module LlmCostTracker
|
|
|
23
30
|
@provider_project_id = provider_project_id
|
|
24
31
|
@provider_api_key_id = provider_api_key_id
|
|
25
32
|
@provider_workspace_id = provider_workspace_id
|
|
26
|
-
@batch = batch
|
|
27
33
|
@pricing_mode = pricing_mode
|
|
28
34
|
@metadata = (metadata || {}).deep_dup
|
|
29
35
|
@context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
|
|
@@ -38,18 +44,6 @@ module LlmCostTracker
|
|
|
38
44
|
@mutex = Mutex.new
|
|
39
45
|
end
|
|
40
46
|
|
|
41
|
-
def model
|
|
42
|
-
@mutex.synchronize { @model }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def metadata
|
|
46
|
-
@mutex.synchronize { @metadata.deep_dup }
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def provider_response_id
|
|
50
|
-
@mutex.synchronize { @provider_response_id }
|
|
51
|
-
end
|
|
52
|
-
|
|
53
47
|
def model=(value)
|
|
54
48
|
@mutex.synchronize do
|
|
55
49
|
ensure_open!
|
|
@@ -72,16 +66,20 @@ module LlmCostTracker
|
|
|
72
66
|
end
|
|
73
67
|
|
|
74
68
|
def usage(input_tokens:, output_tokens:, **extra)
|
|
69
|
+
if extra.key?(:batch)
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"`batch:` is no longer accepted by stream.usage; " \
|
|
72
|
+
"pass `pricing_mode: :batch` to track_stream"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
75
|
@mutex.synchronize do
|
|
76
76
|
ensure_open!
|
|
77
77
|
@provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
|
|
78
78
|
@provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
|
|
79
79
|
@provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
|
|
80
80
|
@provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
@explicit_usage = TokenUsage.build(
|
|
84
|
-
**extra.slice(*TokenUsage.members),
|
|
81
|
+
@explicit_usage = Usage::TokenUsage.build(
|
|
82
|
+
**extra.slice(*Usage::TokenUsage.members),
|
|
85
83
|
input_tokens: input_tokens,
|
|
86
84
|
output_tokens: output_tokens
|
|
87
85
|
)
|
|
@@ -110,7 +108,7 @@ module LlmCostTracker
|
|
|
110
108
|
model: @model,
|
|
111
109
|
latency_ms: @latency_ms,
|
|
112
110
|
provider_response_id: @provider_response_id,
|
|
113
|
-
capture_dimensions: capture_dimensions
|
|
111
|
+
capture_dimensions: capture_dimensions,
|
|
114
112
|
pricing_mode: pricing_mode,
|
|
115
113
|
metadata: @metadata.deep_dup,
|
|
116
114
|
context_tags: @context_tags.deep_dup,
|
|
@@ -141,13 +139,11 @@ module LlmCostTracker
|
|
|
141
139
|
end
|
|
142
140
|
end
|
|
143
141
|
|
|
144
|
-
def capture_dimensions
|
|
145
|
-
batch = @batch.nil? ? Event.batch_from_pricing_mode?(pricing_mode).presence : @batch
|
|
142
|
+
def capture_dimensions
|
|
146
143
|
{
|
|
147
144
|
provider_project_id: @provider_project_id.to_s.strip.presence,
|
|
148
145
|
provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
|
|
149
|
-
provider_workspace_id: @provider_workspace_id.to_s.strip.presence
|
|
150
|
-
batch: batch
|
|
146
|
+
provider_workspace_id: @provider_workspace_id.to_s.strip.presence
|
|
151
147
|
}.compact
|
|
152
148
|
end
|
|
153
149
|
|
|
@@ -183,12 +179,8 @@ module LlmCostTracker
|
|
|
183
179
|
end
|
|
184
180
|
|
|
185
181
|
def present_model(value)
|
|
186
|
-
return nil if value.nil?
|
|
187
|
-
|
|
188
182
|
string = value.to_s.presence
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
string
|
|
183
|
+
string unless string == Event::UNKNOWN_MODEL
|
|
192
184
|
end
|
|
193
185
|
|
|
194
186
|
def build_from_explicit_usage(snapshot)
|
|
@@ -197,7 +189,7 @@ module LlmCostTracker
|
|
|
197
189
|
model: snapshot[:model] || Event::UNKNOWN_MODEL,
|
|
198
190
|
token_usage: snapshot[:explicit_usage],
|
|
199
191
|
stream: true,
|
|
200
|
-
usage_source:
|
|
192
|
+
usage_source: Usage::Source::MANUAL,
|
|
201
193
|
pricing_mode: snapshot[:pricing_mode],
|
|
202
194
|
**snapshot.fetch(:capture_dimensions)
|
|
203
195
|
)
|
|
@@ -207,9 +199,9 @@ module LlmCostTracker
|
|
|
207
199
|
Event.build(
|
|
208
200
|
provider: @provider,
|
|
209
201
|
model: snapshot[:model] || Event::UNKNOWN_MODEL,
|
|
210
|
-
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
202
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
211
203
|
stream: true,
|
|
212
|
-
usage_source:
|
|
204
|
+
usage_source: Usage::Source::UNKNOWN,
|
|
213
205
|
pricing_mode: snapshot[:pricing_mode],
|
|
214
206
|
**snapshot.fetch(:capture_dimensions)
|
|
215
207
|
)
|
|
@@ -224,7 +216,7 @@ module LlmCostTracker
|
|
|
224
216
|
def capture_event(data, type:)
|
|
225
217
|
event = { event: type, data: strip_heavy_payload(data) }
|
|
226
218
|
size = approximate_bytesize(event)
|
|
227
|
-
if @captured_bytes + size <= Capture::
|
|
219
|
+
if @captured_bytes + size <= Capture::SSE::LIMIT_BYTES
|
|
228
220
|
@events << event
|
|
229
221
|
@captured_bytes += size
|
|
230
222
|
else
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "active_support/core_ext/object/deep_dup"
|
|
4
4
|
require "active_support/core_ext/object/try"
|
|
5
5
|
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "sdk_payload"
|
|
7
7
|
|
|
8
8
|
module LlmCostTracker
|
|
9
9
|
module Capture
|
|
@@ -26,13 +26,24 @@ module LlmCostTracker
|
|
|
26
26
|
if @stream.instance_variable_defined?(:@iterator)
|
|
27
27
|
iterator = @stream.instance_variable_get(:@iterator)
|
|
28
28
|
if iterator.respond_to?(:each)
|
|
29
|
-
@stream.instance_variable_set(:@iterator,
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
@stream.instance_variable_set(:@iterator,
|
|
30
|
+
Enumerator.new do |yielder|
|
|
31
|
+
each_from(iterator) { |event| yielder << event }
|
|
32
|
+
end)
|
|
32
33
|
iterator_wrapped = true
|
|
33
34
|
end
|
|
34
35
|
end
|
|
35
|
-
|
|
36
|
+
each_wrapped = false
|
|
37
|
+
if !iterator_wrapped && @stream.respond_to?(:each)
|
|
38
|
+
wrap_each
|
|
39
|
+
each_wrapped = true
|
|
40
|
+
end
|
|
41
|
+
unless iterator_wrapped || each_wrapped
|
|
42
|
+
Logging.warn(
|
|
43
|
+
"stream integration found no wrappable iterator on #{@stream.class} " \
|
|
44
|
+
"(missing both `@iterator` ivar and `#each`); usage will not be captured"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
36
47
|
|
|
37
48
|
register_orphan_finalizer
|
|
38
49
|
@stream
|
|
@@ -75,35 +86,13 @@ module LlmCostTracker
|
|
|
75
86
|
|
|
76
87
|
def capture(event)
|
|
77
88
|
raw_payload = event.try(:deep_to_h) || event.try(:to_h) || {}
|
|
78
|
-
payload = normalize(raw_payload)
|
|
89
|
+
payload = SdkPayload.normalize(raw_payload)
|
|
79
90
|
type = event.try(:type) || payload["type"]
|
|
80
91
|
@collector.event(payload, type: type&.to_s)
|
|
81
92
|
rescue StandardError => e
|
|
82
93
|
warn_capture_failure(e)
|
|
83
94
|
end
|
|
84
95
|
|
|
85
|
-
def normalize(value)
|
|
86
|
-
case value
|
|
87
|
-
when Hash
|
|
88
|
-
value.each_with_object({}) do |(key, nested), normalized|
|
|
89
|
-
normalized[key.to_s] = normalize(nested)
|
|
90
|
-
end
|
|
91
|
-
when Array
|
|
92
|
-
value.map { |nested| normalize(nested) }
|
|
93
|
-
when Symbol
|
|
94
|
-
value.to_s
|
|
95
|
-
when NilClass
|
|
96
|
-
nil
|
|
97
|
-
else
|
|
98
|
-
converted = begin
|
|
99
|
-
value.try(:deep_to_h) || value.try(:to_h)
|
|
100
|
-
rescue StandardError
|
|
101
|
-
nil
|
|
102
|
-
end
|
|
103
|
-
converted ? normalize(converted) : value.deep_dup
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
96
|
def warn_capture_failure(error)
|
|
108
97
|
should_warn = @mutex.synchronize do
|
|
109
98
|
next false if @capture_failed
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "check"
|
|
4
|
+
require_relative "ingestion"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
class CaptureVerifier
|
|
8
|
+
class << self
|
|
9
|
+
def call
|
|
10
|
+
new.checks
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def report(checks = call)
|
|
14
|
+
(["LLM Cost Tracker capture verification"] + checks.map do |check|
|
|
15
|
+
"[#{check.status}] #{check.name}: #{check.message}"
|
|
16
|
+
end).join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def healthy?(checks = call)
|
|
20
|
+
checks.none? { |check| check.status == :error }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def checks
|
|
25
|
+
[
|
|
26
|
+
enabled_check,
|
|
27
|
+
*integration_checks,
|
|
28
|
+
*storage_checks
|
|
29
|
+
].compact
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def enabled_check
|
|
35
|
+
return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
|
|
36
|
+
|
|
37
|
+
Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def integration_checks
|
|
41
|
+
enabled = LlmCostTracker.configuration.instrumented_integrations
|
|
42
|
+
if enabled.empty?
|
|
43
|
+
return [
|
|
44
|
+
Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
LlmCostTracker::Integrations.checks.map do |check|
|
|
49
|
+
check.with(name: "sdk integration #{check.name}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def storage_checks
|
|
54
|
+
LlmCostTracker::Ingestion.verify
|
|
55
|
+
rescue LlmCostTracker::Error => e
|
|
56
|
+
[Check.new(:error, "storage", e.message)]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Charges
|
|
7
|
+
Cost = Data.define(:components, :total, :currency) do
|
|
8
|
+
def self.from_h(attributes)
|
|
9
|
+
components = attributes.key?(:components) ? attributes[:components] : attributes.except(:total_cost, :currency)
|
|
10
|
+
total = attributes.fetch(:total) { attributes[:total_cost] }
|
|
11
|
+
new(
|
|
12
|
+
components: components.transform_values { |value| BigDecimal(value.to_s) }.freeze,
|
|
13
|
+
total: total && BigDecimal(total.to_s),
|
|
14
|
+
currency: attributes[:currency]
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
components: components.transform_values { |value| value.to_s("F") },
|
|
21
|
+
total: total&.to_s("F"),
|
|
22
|
+
currency: currency
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "../usage/source"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
|
-
module
|
|
6
|
+
module Charges
|
|
7
7
|
module CostStatus
|
|
8
8
|
COMPLETE = "complete"
|
|
9
9
|
FREE = "free"
|
|
10
10
|
PARTIAL = "partial"
|
|
11
11
|
UNKNOWN = "unknown"
|
|
12
|
+
INCOMPLETE = [UNKNOWN, PARTIAL].freeze
|
|
13
|
+
|
|
14
|
+
def self.unknown_pricing_sql(total_cost: "total_cost", cost_status: "cost_status")
|
|
15
|
+
statuses = INCOMPLETE.map { |status| ActiveRecord::Base.connection.quote(status) }.join(", ")
|
|
16
|
+
"#{total_cost} IS NULL OR #{cost_status} IN (#{statuses})"
|
|
17
|
+
end
|
|
12
18
|
|
|
13
19
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
14
|
-
def self.call(token_usage:,
|
|
20
|
+
def self.call(token_usage:,
|
|
21
|
+
usage_source:,
|
|
22
|
+
token_cost:,
|
|
23
|
+
service_line_items:,
|
|
24
|
+
total_cost:,
|
|
15
25
|
token_pricing_partial: false)
|
|
16
|
-
return UNKNOWN if usage_source ==
|
|
26
|
+
return UNKNOWN if usage_source == Usage::Source::UNKNOWN
|
|
17
27
|
|
|
18
28
|
token_billable = token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
|
|
19
29
|
service_billable = false
|