llm_cost_tracker 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- 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 +16 -4
- 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/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- 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 +119 -120
- 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/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- 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/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- 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/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -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
|
|
|
@@ -10,36 +10,32 @@ module LlmCostTracker
|
|
|
10
10
|
PARTIAL = "partial"
|
|
11
11
|
UNKNOWN = "unknown"
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return UNKNOWN if usage_source == :unknown
|
|
13
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
14
|
+
def self.call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
|
|
15
|
+
token_pricing_partial: false)
|
|
16
|
+
return UNKNOWN if usage_source == :unknown
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
service_line_items.each do |line_item|
|
|
26
|
-
next unless line_item.billable?
|
|
18
|
+
token_billable = token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
|
|
19
|
+
service_billable = false
|
|
20
|
+
service_priced = false
|
|
21
|
+
service_unpriced = false
|
|
22
|
+
service_line_items.each do |line_item|
|
|
23
|
+
next unless line_item.billable?
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
service_billable = true
|
|
26
|
+
service_priced ||= line_item.priced?
|
|
27
|
+
service_unpriced ||= line_item.unpriced?
|
|
28
|
+
break if service_priced && service_unpriced
|
|
29
|
+
end
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
|
|
32
|
+
unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
|
|
33
|
+
return UNKNOWN if unpriced && !priced
|
|
34
|
+
return PARTIAL if unpriced
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
end
|
|
41
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
36
|
+
total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
|
|
42
37
|
end
|
|
38
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
43
39
|
end
|
|
44
40
|
end
|
|
45
41
|
end
|
|
@@ -30,28 +30,11 @@ module LlmCostTracker
|
|
|
30
30
|
|
|
31
31
|
class LineItem
|
|
32
32
|
USD = "USD"
|
|
33
|
-
OPTIONAL_ATTRIBUTES = %i[
|
|
34
|
-
pricing_basis
|
|
35
|
-
price_key
|
|
36
|
-
price_source
|
|
37
|
-
price_source_version
|
|
38
|
-
provider_field
|
|
39
|
-
provider_item_id
|
|
40
|
-
].freeze
|
|
41
|
-
SYMBOL_ATTRIBUTES = %i[
|
|
42
|
-
kind
|
|
43
|
-
direction
|
|
44
|
-
modality
|
|
45
|
-
cache_state
|
|
46
|
-
unit
|
|
47
|
-
pricing_basis
|
|
48
|
-
price_source
|
|
49
|
-
].freeze
|
|
50
33
|
|
|
51
34
|
def self.build(attributes)
|
|
52
35
|
attributes = attributes.to_h
|
|
53
36
|
component = component_for(attributes)
|
|
54
|
-
|
|
37
|
+
new(
|
|
55
38
|
kind: symbol_or_nil(attributes[:kind]) || component&.kind,
|
|
56
39
|
direction: symbol_or_nil(attributes[:direction]) || component&.direction,
|
|
57
40
|
modality: symbol_or_nil(attributes[:modality]) || component&.modality,
|
|
@@ -63,38 +46,30 @@ module LlmCostTracker
|
|
|
63
46
|
cost: decimal_or_nil(attributes[:cost]),
|
|
64
47
|
currency: attributes[:currency] || USD,
|
|
65
48
|
cost_status: cost_status_for(attributes),
|
|
49
|
+
pricing_basis: symbol_or_nil(attributes[:pricing_basis]),
|
|
50
|
+
price_key: attributes[:price_key],
|
|
51
|
+
price_source: symbol_or_nil(attributes[:price_source]),
|
|
52
|
+
price_source_version: attributes[:price_source_version],
|
|
53
|
+
provider_field: attributes[:provider_field],
|
|
54
|
+
provider_item_id: attributes[:provider_item_id],
|
|
66
55
|
details: attributes[:details] || {}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
new(**normalized)
|
|
56
|
+
)
|
|
70
57
|
end
|
|
71
58
|
|
|
72
59
|
def self.from_token_usage(token_usage)
|
|
73
60
|
return [] unless token_usage
|
|
74
61
|
|
|
75
|
-
|
|
76
|
-
quantity = token_usage.public_send(component.token_key)
|
|
62
|
+
token_usage.priced_quantities.filter_map do |key, quantity|
|
|
77
63
|
next unless quantity.positive?
|
|
78
64
|
|
|
79
|
-
|
|
65
|
+
component = Components::BY_KEY.fetch(key)
|
|
66
|
+
build(
|
|
80
67
|
kind: component.kind,
|
|
81
68
|
direction: component.direction,
|
|
82
69
|
modality: component.modality,
|
|
83
70
|
cache_state: component.cache_state,
|
|
84
|
-
quantity:
|
|
85
|
-
unit: component.unit
|
|
86
|
-
rate_amount: nil,
|
|
87
|
-
rate_quantity: BigDecimal("1"),
|
|
88
|
-
cost: nil,
|
|
89
|
-
currency: USD,
|
|
90
|
-
cost_status: CostStatus::UNKNOWN,
|
|
91
|
-
pricing_basis: nil,
|
|
92
|
-
price_key: nil,
|
|
93
|
-
price_source: nil,
|
|
94
|
-
price_source_version: nil,
|
|
95
|
-
provider_field: nil,
|
|
96
|
-
provider_item_id: nil,
|
|
97
|
-
details: {}
|
|
71
|
+
quantity: quantity,
|
|
72
|
+
unit: component.unit
|
|
98
73
|
)
|
|
99
74
|
end
|
|
100
75
|
end
|
|
@@ -132,16 +107,7 @@ module LlmCostTracker
|
|
|
132
107
|
decimal_or_nil(value) || BigDecimal("0")
|
|
133
108
|
end
|
|
134
109
|
|
|
135
|
-
|
|
136
|
-
OPTIONAL_ATTRIBUTES.to_h do |key|
|
|
137
|
-
value = attributes[key]
|
|
138
|
-
value = value.to_sym if value.is_a?(String) && SYMBOL_ATTRIBUTES.include?(key)
|
|
139
|
-
[key, value]
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero,
|
|
144
|
-
:optional_attributes_for
|
|
110
|
+
private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero
|
|
145
111
|
|
|
146
112
|
def billable?
|
|
147
113
|
quantity.positive?
|
|
@@ -163,7 +129,7 @@ module LlmCostTracker
|
|
|
163
129
|
cost || BigDecimal("0")
|
|
164
130
|
end
|
|
165
131
|
|
|
166
|
-
def
|
|
132
|
+
def with_rate(rate)
|
|
167
133
|
rate_amount = rate.fetch(:amount)
|
|
168
134
|
rate_quantity = rate.fetch(:quantity)
|
|
169
135
|
applied_cost = (quantity / rate_quantity) * rate_amount
|
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
3
5
|
require_relative "logging"
|
|
4
6
|
require_relative "ledger"
|
|
7
|
+
require_relative "pricing/estimator"
|
|
5
8
|
|
|
6
9
|
module LlmCostTracker
|
|
7
10
|
class Budget
|
|
8
11
|
BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
|
|
9
12
|
|
|
10
13
|
class << self
|
|
11
|
-
def enforce!
|
|
14
|
+
def enforce!(provider: nil, model: nil, request: nil)
|
|
12
15
|
config = LlmCostTracker.configuration
|
|
13
16
|
return unless config.budget_exceeded_behavior == :block_requests
|
|
14
17
|
|
|
18
|
+
estimate = estimate_cost(provider: provider, model: model, request: request)
|
|
19
|
+
raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
|
|
20
|
+
|
|
15
21
|
budgets = { monthly: config.monthly_budget, daily: config.daily_budget }.compact
|
|
16
22
|
return if budgets.empty?
|
|
17
23
|
|
|
18
24
|
totals = totals_for(budgets.keys, time: Time.now.utc)
|
|
19
25
|
|
|
20
26
|
budgets.each do |budget_type, budget|
|
|
21
|
-
total = totals.fetch(budget_type)
|
|
27
|
+
total = totals.fetch(budget_type) + estimate
|
|
22
28
|
next unless total >= budget
|
|
23
29
|
|
|
24
30
|
raise BudgetExceededError.new(**budget_payload(
|
|
25
|
-
budget_type: budget_type, total: total, budget: budget, last_event: nil
|
|
31
|
+
budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
|
|
26
32
|
))
|
|
27
33
|
end
|
|
28
34
|
end
|
|
@@ -44,6 +50,20 @@ module LlmCostTracker
|
|
|
44
50
|
|
|
45
51
|
private
|
|
46
52
|
|
|
53
|
+
def estimate_cost(provider:, model:, request:)
|
|
54
|
+
return BigDecimal("0") unless provider && model && request
|
|
55
|
+
|
|
56
|
+
Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def raise_per_call_pre_send(estimate, budget)
|
|
60
|
+
return unless estimate >= budget
|
|
61
|
+
|
|
62
|
+
raise BudgetExceededError.new(**budget_payload(
|
|
63
|
+
budget_type: :per_call, total: estimate, budget: budget, last_event: nil, stage: :pre_send
|
|
64
|
+
))
|
|
65
|
+
end
|
|
66
|
+
|
|
47
67
|
def check_per_call_budget(event, config)
|
|
48
68
|
budget = config.per_call_budget
|
|
49
69
|
return unless budget
|
|
@@ -70,7 +90,8 @@ module LlmCostTracker
|
|
|
70
90
|
budget_type: budget_type,
|
|
71
91
|
total: total,
|
|
72
92
|
budget: budget,
|
|
73
|
-
last_event: last_event
|
|
93
|
+
last_event: last_event,
|
|
94
|
+
stage: :post_spend
|
|
74
95
|
)
|
|
75
96
|
|
|
76
97
|
if notify_exceeded?(config, budget_type: budget_type, total: total, budget: budget, last_event: last_event)
|
|
@@ -79,19 +100,19 @@ module LlmCostTracker
|
|
|
79
100
|
raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(config.budget_exceeded_behavior)
|
|
80
101
|
end
|
|
81
102
|
|
|
82
|
-
def budget_payload(budget_type:, total:, budget:, last_event:)
|
|
103
|
+
def budget_payload(budget_type:, total:, budget:, last_event:, stage:)
|
|
83
104
|
{
|
|
84
105
|
budget_type: budget_type,
|
|
85
106
|
total: total,
|
|
86
107
|
budget: budget,
|
|
87
|
-
last_event: last_event
|
|
108
|
+
last_event: last_event,
|
|
109
|
+
stage: stage
|
|
88
110
|
}
|
|
89
111
|
end
|
|
90
112
|
|
|
91
113
|
def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
|
|
92
114
|
return false unless config.on_budget_exceeded
|
|
93
|
-
return true
|
|
94
|
-
return true if budget_type == :per_call
|
|
115
|
+
return true if !last_event&.total_cost || budget_type == :per_call
|
|
95
116
|
|
|
96
117
|
total - last_event.total_cost < budget
|
|
97
118
|
end
|