llm_cost_tracker 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +4 -1
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +15 -3
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +118 -119
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/budget.rb +1 -2
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
- data/lib/llm_cost_tracker/configuration.rb +3 -20
- data/lib/llm_cost_tracker/doctor.rb +74 -8
- data/lib/llm_cost_tracker/event.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
- data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers.rb +0 -2
- data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
- data/lib/llm_cost_tracker/pricing.rb +1 -18
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -4
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/tracker.rb +1 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- metadata +14 -6
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
|
@@ -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
|
+
<% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
|
|
15
16
|
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
</div>
|
|
33
|
-
|
|
34
|
-
<div class="lct-call-summary">
|
|
35
|
-
<div class="lct-call-summary-item">
|
|
36
|
-
<span class="lct-call-summary-label">Pricing</span>
|
|
37
|
-
<strong><%= pricing_status(@call) %></strong>
|
|
38
|
-
</div>
|
|
39
|
-
<div class="lct-call-summary-item">
|
|
40
|
-
<span class="lct-call-summary-label">Total cost</span>
|
|
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>
|
|
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>
|
|
52
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>
|
|
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.reject { |li| li.unit == "token" }.sort_by(&:position) %>
|
|
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 %>
|