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
|
@@ -12,156 +12,155 @@ end %>
|
|
|
12
12
|
{ label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
|
|
13
13
|
end %>
|
|
14
14
|
<% end %>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<strong><%= optional_money(@call.total_cost) %></strong>
|
|
42
|
-
</div>
|
|
43
|
-
<div class="lct-call-summary-item">
|
|
44
|
-
<span class="lct-call-summary-label">Total tokens</span>
|
|
45
|
-
<strong><%= number(@call.total_tokens) %></strong>
|
|
46
|
-
</div>
|
|
47
|
-
<div class="lct-call-summary-item">
|
|
48
|
-
<span class="lct-call-summary-label">Latency</span>
|
|
49
|
-
<strong><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></strong>
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
15
|
+
<% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
|
|
16
|
+
|
|
17
|
+
<p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
|
|
18
|
+
<h2 class="lct-page-title"><code class="lct-code-id"><%= @call.model %></code> <span class="lct-page-title-meta">#<%= @call.id %></span></h2>
|
|
19
|
+
<p class="lct-page-subtitle">
|
|
20
|
+
<span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= @call.provider %>"></span><%= @call.provider %></span>
|
|
21
|
+
<span class="lct-page-subtitle-sep">·</span>
|
|
22
|
+
<span><%= format_date(@call.tracked_at) %></span>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<div class="lct-stat-grid">
|
|
26
|
+
<div class="lct-stat">
|
|
27
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Pricing</p></div>
|
|
28
|
+
<p class="lct-stat-value-sm"><%= pricing_status(@call) %></p>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="lct-stat">
|
|
31
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Total cost</p></div>
|
|
32
|
+
<p class="lct-stat-value<%= ' lct-num-muted' if @call.total_cost.nil? %>"><%= optional_money(@call.total_cost) %></p>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="lct-stat">
|
|
35
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Total tokens</p></div>
|
|
36
|
+
<p class="lct-stat-value"><%= number(@call.total_tokens) %></p>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="lct-stat">
|
|
39
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Latency</p></div>
|
|
40
|
+
<p class="lct-stat-value<%= ' lct-num-muted' if @call.latency_ms.nil? %>"><%= @call.latency_ms ? number(@call.latency_ms) : "n/a" %><% if @call.latency_ms %><span class="lct-stat-unit">ms</span><% end %></p>
|
|
52
41
|
</div>
|
|
42
|
+
</div>
|
|
53
43
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
44
|
+
<div class="lct-grid-2">
|
|
45
|
+
<section class="lct-panel">
|
|
46
|
+
<div class="lct-panel-head"><h2 class="lct-panel-title">Token mix</h2></div>
|
|
47
|
+
<div class="lct-panel-body">
|
|
57
48
|
<%= render "llm_cost_tracker/shared/metric_stack", segments: token_segments, empty_message: "No token data for this call." %>
|
|
58
|
-
</
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
59
51
|
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
<section class="lct-panel">
|
|
53
|
+
<div class="lct-panel-head"><h2 class="lct-panel-title">Cost mix</h2></div>
|
|
54
|
+
<div class="lct-panel-body">
|
|
62
55
|
<% if @call.total_cost.nil? %>
|
|
63
|
-
<p class="lct-
|
|
56
|
+
<p class="lct-stack-empty">Pricing not available for this call.</p>
|
|
64
57
|
<% else %>
|
|
65
58
|
<%= render "llm_cost_tracker/shared/metric_stack", segments: cost_segments, empty_message: "No cost breakdown for this call." %>
|
|
66
59
|
<% end %>
|
|
67
|
-
</
|
|
68
|
-
</
|
|
69
|
-
|
|
70
|
-
<div class="lct-detail-grid">
|
|
71
|
-
<dl class="lct-dl">
|
|
72
|
-
<dt>Cost Status</dt>
|
|
73
|
-
<dd><%= @call.cost_status.presence || "n/a" %></dd>
|
|
74
|
-
|
|
75
|
-
<dt>Latency</dt>
|
|
76
|
-
<dd><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></dd>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
</div>
|
|
77
63
|
|
|
64
|
+
<section class="lct-panel">
|
|
65
|
+
<div class="lct-panel-head"><h2 class="lct-panel-title">Provider context</h2></div>
|
|
66
|
+
<dl class="lct-meta-strip">
|
|
67
|
+
<div class="lct-meta-strip-item">
|
|
78
68
|
<dt>Batch</dt>
|
|
79
69
|
<dd><%= @call.batch? ? "yes" : "no" %></dd>
|
|
80
|
-
|
|
70
|
+
</div>
|
|
71
|
+
<% if @call.has_attribute?(:stream) %>
|
|
72
|
+
<div class="lct-meta-strip-item">
|
|
73
|
+
<dt>Stream</dt>
|
|
74
|
+
<dd><%= @call.stream? ? "yes" : "no" %></dd>
|
|
75
|
+
</div>
|
|
76
|
+
<% end %>
|
|
77
|
+
<% if @call.has_attribute?(:usage_source) && @call.usage_source.present? %>
|
|
78
|
+
<div class="lct-meta-strip-item">
|
|
79
|
+
<dt>Usage source</dt>
|
|
80
|
+
<dd><code class="lct-code-id"><%= @call.usage_source %></code></dd>
|
|
81
|
+
</div>
|
|
82
|
+
<% end %>
|
|
83
|
+
<div class="lct-meta-strip-item">
|
|
81
84
|
<dt>Response ID</dt>
|
|
82
|
-
<dd><%= @call.provider_response_id.
|
|
83
|
-
|
|
85
|
+
<dd><%= @call.provider_response_id.present? ? content_tag(:code, @call.provider_response_id, class: "lct-code-id") : "n/a" %></dd>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="lct-meta-strip-item">
|
|
84
88
|
<dt>Project ID</dt>
|
|
85
|
-
<dd><%= @call.provider_project_id.present? ? LlmCostTracker::Masking.mask_value(:provider_project_id, @call.provider_project_id) : "n/a" %></dd>
|
|
86
|
-
|
|
89
|
+
<dd><%= @call.provider_project_id.present? ? content_tag(:code, LlmCostTracker::Masking.mask_value(:provider_project_id, @call.provider_project_id), class: "lct-code-id") : "n/a" %></dd>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="lct-meta-strip-item">
|
|
87
92
|
<dt>API Key ID</dt>
|
|
88
|
-
<dd><%= @call.provider_api_key_id.present? ? LlmCostTracker::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id) : "n/a" %></dd>
|
|
89
|
-
|
|
93
|
+
<dd><%= @call.provider_api_key_id.present? ? content_tag(:code, LlmCostTracker::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id), class: "lct-code-id") : "n/a" %></dd>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="lct-meta-strip-item">
|
|
90
96
|
<dt>Workspace ID</dt>
|
|
91
|
-
<dd><%= @call.provider_workspace_id.present? ? LlmCostTracker::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id) : "n/a" %></dd>
|
|
92
|
-
</
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
<% priced_components.each do |component| %>
|
|
96
|
-
<dt><%= component.fetch(:label).titleize %> Tokens</dt>
|
|
97
|
-
<dd><%= number(@call[component.fetch(:token_key)]) %></dd>
|
|
98
|
-
<% end %>
|
|
99
|
-
|
|
100
|
-
<dt>Total Tokens</dt>
|
|
101
|
-
<dd><%= number(@call.total_tokens) %></dd>
|
|
102
|
-
</dl>
|
|
103
|
-
|
|
104
|
-
<dl class="lct-dl">
|
|
105
|
-
<% priced_components.each do |component| %>
|
|
106
|
-
<dt><%= component.fetch(:label).titleize %> Cost</dt>
|
|
107
|
-
<dd><%= optional_money(line_item_costs_by_component[component.fetch(:price_key)]) %></dd>
|
|
108
|
-
<% end %>
|
|
97
|
+
<dd><%= @call.provider_workspace_id.present? ? content_tag(:code, LlmCostTracker::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id), class: "lct-code-id") : "n/a" %></dd>
|
|
98
|
+
</div>
|
|
99
|
+
</dl>
|
|
100
|
+
</section>
|
|
109
101
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
<section class="lct-panel">
|
|
103
|
+
<div class="lct-panel-head"><h2 class="lct-panel-title">Tags</h2></div>
|
|
104
|
+
<div class="lct-panel-body">
|
|
105
|
+
<% if @call.parsed_tags.empty? %>
|
|
106
|
+
<p class="lct-stack-empty">(untagged)</p>
|
|
107
|
+
<% else %>
|
|
108
|
+
<span class="lct-tag-chips">
|
|
109
|
+
<% @call.parsed_tags.each do |k, v| %>
|
|
110
|
+
<span class="lct-tag-chip"><span class="lct-tag-chip-key"><%= k %></span>=<%= v %></span>
|
|
111
|
+
<% end %>
|
|
112
|
+
</span>
|
|
113
|
+
<% end %>
|
|
113
114
|
</div>
|
|
114
115
|
</section>
|
|
115
116
|
|
|
116
|
-
<% service_line_items = @call.line_items.where.not(unit: "token").order(:position).to_a %>
|
|
117
117
|
<% if service_line_items.any? %>
|
|
118
118
|
<section class="lct-panel">
|
|
119
|
-
<h2 class="lct-
|
|
120
|
-
<
|
|
121
|
-
<
|
|
122
|
-
<
|
|
119
|
+
<div class="lct-panel-head"><h2 class="lct-panel-title">Service charges</h2></div>
|
|
120
|
+
<table class="lct-tbl">
|
|
121
|
+
<thead>
|
|
122
|
+
<tr>
|
|
123
|
+
<th>Component</th>
|
|
124
|
+
<th>Unit</th>
|
|
125
|
+
<th class="lct-num">Quantity</th>
|
|
126
|
+
<th class="lct-num">Rate</th>
|
|
127
|
+
<th class="lct-num">Cost</th>
|
|
128
|
+
<th>Status</th>
|
|
129
|
+
</tr>
|
|
130
|
+
</thead>
|
|
131
|
+
<tbody>
|
|
132
|
+
<% service_line_items.each do |line_item| %>
|
|
123
133
|
<tr>
|
|
124
|
-
<
|
|
125
|
-
<
|
|
126
|
-
<
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
<
|
|
134
|
+
<td><code class="lct-code-id"><%= line_item.kind %></code></td>
|
|
135
|
+
<td><%= line_item.unit %></td>
|
|
136
|
+
<td class="lct-num"><%= line_item.quantity %></td>
|
|
137
|
+
<td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
|
|
138
|
+
<% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
|
|
139
|
+
<td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
|
|
140
|
+
<td><%= line_item.cost_status %></td>
|
|
130
141
|
</tr>
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
<tr>
|
|
135
|
-
<td><code class="lct-code"><%= line_item.kind %></code></td>
|
|
136
|
-
<td><%= line_item.unit %></td>
|
|
137
|
-
<td class="lct-num"><%= line_item.quantity %></td>
|
|
138
|
-
<td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
|
|
139
|
-
<% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
|
|
140
|
-
<td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
|
|
141
|
-
<td><%= line_item.cost_status %></td>
|
|
142
|
-
</tr>
|
|
143
|
-
<% end %>
|
|
144
|
-
</tbody>
|
|
145
|
-
</table>
|
|
146
|
-
</div>
|
|
142
|
+
<% end %>
|
|
143
|
+
</tbody>
|
|
144
|
+
</table>
|
|
147
145
|
</section>
|
|
148
146
|
<% end %>
|
|
149
147
|
|
|
150
148
|
<% if @call.pricing_snapshot.present? %>
|
|
151
|
-
<
|
|
152
|
-
<
|
|
149
|
+
<details class="lct-panel lct-disclose">
|
|
150
|
+
<summary class="lct-panel-head lct-disclose-summary">
|
|
151
|
+
<h2 class="lct-panel-title">Pricing snapshot</h2>
|
|
152
|
+
<span class="lct-disclose-hint">show JSON</span>
|
|
153
|
+
</summary>
|
|
153
154
|
<pre class="lct-pre"><%= safe_json(@call.pricing_snapshot) %></pre>
|
|
154
|
-
</
|
|
155
|
+
</details>
|
|
155
156
|
<% end %>
|
|
156
157
|
|
|
157
|
-
<section class="lct-panel">
|
|
158
|
-
<h2 class="lct-section-title">Tags</h2>
|
|
159
|
-
<pre class="lct-pre"><%= safe_json(@call.parsed_tags) %></pre>
|
|
160
|
-
</section>
|
|
161
|
-
|
|
162
158
|
<% if @call.has_attribute?("metadata") %>
|
|
163
|
-
<
|
|
164
|
-
<
|
|
159
|
+
<details class="lct-panel lct-disclose">
|
|
160
|
+
<summary class="lct-panel-head lct-disclose-summary">
|
|
161
|
+
<h2 class="lct-panel-title">Metadata</h2>
|
|
162
|
+
<span class="lct-disclose-hint">show JSON</span>
|
|
163
|
+
</summary>
|
|
165
164
|
<pre class="lct-pre"><%= safe_json(LlmCostTracker::Masking.mask_hash(masked_metadata_hash(@call.read_attribute("metadata")))) %></pre>
|
|
166
|
-
</
|
|
165
|
+
</details>
|
|
167
166
|
<% end %>
|
|
@@ -1,202 +1,163 @@
|
|
|
1
|
-
|
|
1
|
+
<div class="lct-filter-row">
|
|
2
|
+
<%= render "llm_cost_tracker/shared/filter_pill_date", path: root_path %>
|
|
3
|
+
<%= render "llm_cost_tracker/shared/filter_pill_provider", path: root_path %>
|
|
4
|
+
<%= render "llm_cost_tracker/shared/filter_pill_model", path: root_path %>
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
filter_scope: overview_filter_scope,
|
|
7
|
-
defaults: { from: @from_date.iso8601, to: @to_date.iso8601 } %>
|
|
6
|
+
<% if params[:provider].present? || params[:model].present? %>
|
|
7
|
+
<%= link_to "× Clear filters", root_path(current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
|
|
8
|
+
<% end %>
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
<span class="lct-filter-row-meta">
|
|
11
|
+
<%= number(@stats.total_calls) %> call<%= "s" unless @stats.total_calls.to_i == 1 %> · <%= money(@stats.total_cost) %>
|
|
12
|
+
</span>
|
|
13
|
+
</div>
|
|
11
14
|
|
|
12
15
|
<% if @stats.total_calls.to_i.zero? %>
|
|
13
16
|
<section class="lct-panel lct-empty">
|
|
14
17
|
<h2 class="lct-state-title">No LLM calls yet</h2>
|
|
15
18
|
<p class="lct-state-copy">Tracked requests will appear here after your application records its first LLM API call.</p>
|
|
16
|
-
<div class="lct-state-actions">
|
|
17
|
-
<%= link_to "Calls", calls_path, class: "lct-button lct-button-secondary" %>
|
|
18
|
-
</div>
|
|
19
19
|
</section>
|
|
20
20
|
<% else %>
|
|
21
21
|
<% if @stats.unknown_pricing_count.to_i.positive? %>
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
</
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
</aside>
|
|
22
|
+
<div class="lct-alert lct-alert-warn">
|
|
23
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
|
|
24
|
+
<span>
|
|
25
|
+
<strong><%= number(@stats.unknown_pricing_count) %> call<%= "s" unless @stats.unknown_pricing_count.to_i == 1 %> with incomplete pricing</strong>
|
|
26
|
+
· <%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of slice
|
|
27
|
+
— update <code>pricing_overrides</code> or <code>prices_file</code>.
|
|
28
|
+
</span>
|
|
29
|
+
<%= link_to "Fix now →", data_quality_path, class: "lct-alert-action" %>
|
|
30
|
+
</div>
|
|
32
31
|
<% end %>
|
|
33
32
|
|
|
34
33
|
<% if @spend_anomaly %>
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</p>
|
|
48
|
-
</div>
|
|
49
|
-
<%= link_to "Calls",
|
|
34
|
+
<div class="lct-alert lct-alert-danger">
|
|
35
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
|
|
36
|
+
<span>
|
|
37
|
+
<strong>Spend anomaly detected</strong>
|
|
38
|
+
· <code><%= @spend_anomaly.fetch(:model) %></code> on <%= @spend_anomaly.fetch(:day).strftime("%b %-d") %>
|
|
39
|
+
<% if @spend_anomaly.fetch(:ratio) %>
|
|
40
|
+
— <%= number_with_precision(@spend_anomaly.fetch(:ratio), precision: 1) %>× its prior 7-day average.
|
|
41
|
+
<% else %>
|
|
42
|
+
— <%= money(@spend_anomaly.fetch(:latest_spend)) %> after seven quiet days.
|
|
43
|
+
<% end %>
|
|
44
|
+
</span>
|
|
45
|
+
<%= link_to "View calls →",
|
|
50
46
|
calls_path(current_query(provider: @spend_anomaly.fetch(:provider), model: @spend_anomaly.fetch(:model), from: @to_date.iso8601, to: @to_date.iso8601, page: nil, per: nil, format: nil)),
|
|
51
|
-
class: "lct-
|
|
52
|
-
</
|
|
47
|
+
class: "lct-alert-action" %>
|
|
48
|
+
</div>
|
|
53
49
|
<% end %>
|
|
54
50
|
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
<div>
|
|
51
|
+
<div class="lct-stat-grid">
|
|
52
|
+
<div class="lct-stat">
|
|
53
|
+
<div class="lct-stat-head">
|
|
58
54
|
<p class="lct-stat-label">Total spend</p>
|
|
59
|
-
<p class="lct-hero-value"><%= money(@stats.total_cost) %></p>
|
|
60
55
|
<% badge = delta_badge(@stats.cost_delta_percent) %>
|
|
61
|
-
<span class="<%= badge[:css_class] %>"
|
|
56
|
+
<span class="<%= badge[:css_class] %>"><%= badge[:text] %></span>
|
|
62
57
|
</div>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
</
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
58
|
+
<p class="lct-stat-value"><%= money(@stats.total_cost) %></p>
|
|
59
|
+
<p class="lct-stat-foot">Avg <%= money(@stats.average_cost_per_call) %> / call</p>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="lct-stat lct-stat-ok">
|
|
62
|
+
<div class="lct-stat-head">
|
|
63
|
+
<p class="lct-stat-label">Calls</p>
|
|
64
|
+
<% badge = delta_badge(@stats.calls_delta_percent, mode: :neutral) %>
|
|
65
|
+
<span class="<%= badge[:css_class] %>"><%= badge[:text] %></span>
|
|
66
|
+
</div>
|
|
67
|
+
<p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
|
|
68
|
+
</div>
|
|
69
|
+
<% if @stats.average_latency_ms %>
|
|
70
|
+
<div class="lct-stat lct-stat-ok">
|
|
71
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Avg latency</p></div>
|
|
72
|
+
<p class="lct-stat-value"><%= number(@stats.average_latency_ms.round) %><span class="lct-stat-unit">ms</span></p>
|
|
73
|
+
</div>
|
|
74
|
+
<% else %>
|
|
75
|
+
<div class="lct-stat">
|
|
76
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Avg latency</p></div>
|
|
77
|
+
<p class="lct-stat-value lct-num-muted">n/a</p>
|
|
78
|
+
</div>
|
|
79
|
+
<% end %>
|
|
80
|
+
<div class="lct-stat <%= @stats.unknown_pricing_count.to_i.positive? ? 'lct-stat-warn' : 'lct-stat-ok' %>">
|
|
81
|
+
<div class="lct-stat-head"><p class="lct-stat-label">Incomplete pricing</p></div>
|
|
82
|
+
<p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
|
|
83
|
+
<p class="lct-stat-foot"><%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of slice</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
85
86
|
|
|
87
|
+
<% if @monthly_budget_status %>
|
|
88
|
+
<% budget = @monthly_budget_status %>
|
|
89
|
+
<section class="lct-panel">
|
|
90
|
+
<div class="lct-panel-head">
|
|
91
|
+
<h2 class="lct-panel-title">Monthly budget</h2>
|
|
92
|
+
<span class="lct-panel-meta">
|
|
93
|
+
<span><%= money(budget[:spent]) %> of <%= money(budget[:budget]) %> · <%= percent(budget[:percent_used]) %></span>
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="lct-panel-body">
|
|
97
|
+
<div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= budget[:progress_percent].round %>">
|
|
98
|
+
<div data-lct-style="<%= inline_style("width: #{budget[:progress_percent]}%") %>" class="lct-budget-fill <%= budget[:fill_modifier] %>"></div>
|
|
99
|
+
<% if budget[:projected_spent].positive? %>
|
|
100
|
+
<span data-lct-style="<%= inline_style("left: calc(#{budget[:projected_marker_percent]}% - 1px)") %>" class="lct-budget-marker" aria-hidden="true"></span>
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
103
|
+
<p class="lct-budget-projection">
|
|
104
|
+
<span>Projected <strong><%= money(budget[:projected_spent]) %></strong> by <%= budget[:projection_end_label] %></span>
|
|
105
|
+
<span class="lct-budget-projection-status <%= budget[:projected_delta_status_class] %>">
|
|
106
|
+
<%= money(budget[:projected_delta_amount]) %> <%= budget[:projected_delta_direction] %> budget
|
|
107
|
+
</span>
|
|
108
|
+
</p>
|
|
86
109
|
</div>
|
|
110
|
+
</section>
|
|
111
|
+
<% end %>
|
|
87
112
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
<div class="lct-budget">
|
|
98
|
-
<div class="lct-budget-head">
|
|
99
|
-
<span>
|
|
100
|
-
<span class="lct-budget-spent"><%= money(budget[:spent]) %></span>
|
|
101
|
-
<span class="lct-budget-of"> of <%= money(budget[:budget]) %></span>
|
|
102
|
-
</span>
|
|
103
|
-
<span class="lct-budget-percent"><%= percent(budget[:percent_used]) %></span>
|
|
104
|
-
</div>
|
|
105
|
-
<div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= budget[:progress_percent].round %>">
|
|
106
|
-
<div data-lct-style="<%= inline_style("width: #{budget[:progress_percent]}%") %>" class="lct-budget-fill <%= budget[:fill_modifier] %>"></div>
|
|
107
|
-
<% if budget[:projected_spent].positive? %>
|
|
108
|
-
<span data-lct-style="<%= inline_style("left: calc(#{budget[:projected_marker_percent]}% - 1px)") %>" class="lct-budget-marker" aria-hidden="true"></span>
|
|
109
|
-
<% end %>
|
|
110
|
-
</div>
|
|
111
|
-
<p class="lct-budget-projection">
|
|
112
|
-
<span>Projected <strong><%= money(budget[:projected_spent]) %></strong> by <%= budget[:projection_end_label] %></span>
|
|
113
|
-
<span class="lct-budget-projection-status <%= budget[:projected_delta_status_class] %>">
|
|
114
|
-
<%= money(budget[:projected_delta_amount]) %> <%= budget[:projected_delta_direction] %> budget
|
|
115
|
-
</span>
|
|
116
|
-
</p>
|
|
117
|
-
<p class="lct-budget-meta">Soft limit: blocking is not atomic under concurrency.</p>
|
|
118
|
-
</div>
|
|
119
|
-
</section>
|
|
120
|
-
<% end %>
|
|
113
|
+
<section class="lct-panel">
|
|
114
|
+
<div class="lct-panel-head">
|
|
115
|
+
<h2 class="lct-panel-title">Daily spend</h2>
|
|
116
|
+
<span class="lct-panel-meta">
|
|
117
|
+
<span class="lct-legend"><span class="lct-legend-dot lct-legend-dot-current"></span>current</span>
|
|
118
|
+
<% if @comparison_series.any? %><span class="lct-legend"><span class="lct-legend-dot lct-legend-dot-prior"></span>prior <%= number(@comparison_series.size) %>d</span><% end %>
|
|
119
|
+
</span>
|
|
121
120
|
</div>
|
|
121
|
+
<%= render "llm_cost_tracker/shared/spend_chart", series: @time_series, comparison_series: @comparison_series %>
|
|
122
122
|
</section>
|
|
123
123
|
|
|
124
|
-
<
|
|
124
|
+
<div class="lct-grid-2">
|
|
125
125
|
<section class="lct-panel">
|
|
126
|
-
<div class="lct-
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
<p class="lct-section-copy">Current slice vs. previous <%= number(@comparison_series.size) %>-day slice.</p>
|
|
130
|
-
</div>
|
|
126
|
+
<div class="lct-panel-head">
|
|
127
|
+
<h2 class="lct-panel-title">Top models</h2>
|
|
128
|
+
<span class="lct-panel-meta"><%= link_to "View all →", models_path(current_query) %></span>
|
|
131
129
|
</div>
|
|
132
|
-
|
|
130
|
+
<table class="lct-tbl">
|
|
131
|
+
<thead><tr><th>Model</th><th class="lct-num">Calls</th><th class="lct-num">Cost</th></tr></thead>
|
|
132
|
+
<tbody>
|
|
133
|
+
<% @top_models.first(5).each do |row| %>
|
|
134
|
+
<tr>
|
|
135
|
+
<td><span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= row.provider %>"></span><%= link_to calls_path(calls_query_for_model(provider: row.provider, model: row.model)), class: "lct-code-id" do %><%= row.model %><% end %></span></td>
|
|
136
|
+
<td class="lct-num"><%= number(row.calls) %></td>
|
|
137
|
+
<td class="lct-num"><%= money(row.total_cost) %></td>
|
|
138
|
+
</tr>
|
|
139
|
+
<% end %>
|
|
140
|
+
</tbody>
|
|
141
|
+
</table>
|
|
133
142
|
</section>
|
|
134
143
|
|
|
135
144
|
<% if @providers.any? %>
|
|
136
145
|
<section class="lct-panel">
|
|
137
|
-
<div class="lct-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<p class="lct-section-copy">Spend share across the selected slice.</p>
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
<table class="lct-table lct-table-compact">
|
|
144
|
-
<thead>
|
|
145
|
-
<tr>
|
|
146
|
-
<th>Provider</th>
|
|
147
|
-
<th class="lct-num">Calls</th>
|
|
148
|
-
<th class="lct-num">Spend</th>
|
|
149
|
-
<th class="lct-num">Share</th>
|
|
150
|
-
<th></th>
|
|
151
|
-
</tr>
|
|
152
|
-
</thead>
|
|
146
|
+
<div class="lct-panel-head"><h2 class="lct-panel-title">By provider</h2></div>
|
|
147
|
+
<table class="lct-tbl">
|
|
148
|
+
<thead><tr><th>Provider</th><th class="lct-num">Calls</th><th class="lct-num">Cost</th><th class="lct-num">Share</th></tr></thead>
|
|
153
149
|
<tbody>
|
|
154
150
|
<% @providers.each do |row| %>
|
|
155
151
|
<tr>
|
|
156
|
-
<td><%= row.provider %></td>
|
|
152
|
+
<td><span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= row.provider %>"></span><%= row.provider %></span></td>
|
|
157
153
|
<td class="lct-num"><%= number(row.calls) %></td>
|
|
158
154
|
<td class="lct-num"><%= money(row.total_cost) %></td>
|
|
159
155
|
<td class="lct-num"><%= percent(row.share_percent) %></td>
|
|
160
|
-
<td><%= link_to "Calls", calls_path(current_query(provider: row.provider, page: nil, per: nil, format: nil)), class: "lct-button lct-button-secondary lct-button-compact" %></td>
|
|
161
156
|
</tr>
|
|
162
157
|
<% end %>
|
|
163
158
|
</tbody>
|
|
164
159
|
</table>
|
|
165
160
|
</section>
|
|
166
161
|
<% end %>
|
|
167
|
-
</
|
|
168
|
-
|
|
169
|
-
<section class="lct-panel">
|
|
170
|
-
<div class="lct-section-head">
|
|
171
|
-
<div>
|
|
172
|
-
<h2 class="lct-section-title">Top Models</h2>
|
|
173
|
-
<p class="lct-section-copy">The heaviest contributors in the current slice.</p>
|
|
174
|
-
</div>
|
|
175
|
-
<%= link_to "View all models", models_path(current_query), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
176
|
-
</div>
|
|
177
|
-
<table class="lct-table lct-table-compact">
|
|
178
|
-
<thead>
|
|
179
|
-
<tr>
|
|
180
|
-
<th>Provider</th>
|
|
181
|
-
<th>Model</th>
|
|
182
|
-
<th class="lct-num">Calls</th>
|
|
183
|
-
<th class="lct-num">Spend</th>
|
|
184
|
-
<th class="lct-num">Avg cost / call</th>
|
|
185
|
-
<th></th>
|
|
186
|
-
</tr>
|
|
187
|
-
</thead>
|
|
188
|
-
<tbody>
|
|
189
|
-
<% @top_models.each do |row| %>
|
|
190
|
-
<tr>
|
|
191
|
-
<td><%= row.provider %></td>
|
|
192
|
-
<td><code class="lct-code"><%= row.model %></code></td>
|
|
193
|
-
<td class="lct-num"><%= number(row.calls) %></td>
|
|
194
|
-
<td class="lct-num"><%= money(row.total_cost) %></td>
|
|
195
|
-
<td class="lct-num"><%= money(row.average_cost_per_call) %></td>
|
|
196
|
-
<td><%= link_to "Calls", calls_path(calls_query_for_model(provider: row.provider, model: row.model)), class: "lct-button lct-button-secondary lct-button-compact" %></td>
|
|
197
|
-
</tr>
|
|
198
|
-
<% end %>
|
|
199
|
-
</tbody>
|
|
200
|
-
</table>
|
|
201
|
-
</section>
|
|
162
|
+
</div>
|
|
202
163
|
<% end %>
|