llm_cost_tracker 0.2.0.alpha2 → 0.2.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 +28 -1
- data/README.md +4 -3
- data/app/assets/llm_cost_tracker/application.css +760 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
- data/app/controllers/llm_cost_tracker/assets_controller.rb +13 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
- data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +42 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
- data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +0 -3
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
- data/app/services/llm_cost_tracker/pagination.rb +6 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
- data/app/views/llm_cost_tracker/calls/index.html.erb +106 -74
- data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +201 -111
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +178 -78
- data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
- data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
- data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
- data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
- data/config/routes.rb +3 -0
- data/lib/llm_cost_tracker/assets.rb +24 -0
- data/lib/llm_cost_tracker/engine.rb +2 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +1 -1
- data/lib/llm_cost_tracker/price_registry.rb +17 -6
- data/lib/llm_cost_tracker/pricing.rb +19 -6
- data/lib/llm_cost_tracker/retention.rb +34 -0
- data/lib/llm_cost_tracker/tag_query.rb +7 -2
- data/lib/llm_cost_tracker/tags_column.rb +13 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +1 -0
- data/lib/tasks/llm_cost_tracker.rake +8 -0
- data/llm_cost_tracker.gemspec +1 -2
- metadata +17 -5
- data/PLAN_0.2.md +0 -488
|
@@ -1,84 +1,100 @@
|
|
|
1
|
-
<section class="lct-panel">
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
<div class="lct-
|
|
5
|
-
|
|
6
|
-
<input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
|
|
1
|
+
<section class="lct-panel lct-toolbar">
|
|
2
|
+
<div class="lct-toolbar-head">
|
|
3
|
+
<h2 class="lct-section-title">Calls</h2>
|
|
4
|
+
<div class="lct-toolbar-actions">
|
|
5
|
+
<%= link_to "Export CSV", calls_path(current_query(format: :csv)), class: "lct-button lct-button-secondary" %>
|
|
7
6
|
</div>
|
|
7
|
+
</div>
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
9
|
+
<form class="lct-filters" action="<%= calls_path %>" method="get">
|
|
10
|
+
<div class="lct-filter-row lct-filter-row-with-sort">
|
|
11
|
+
<div class="lct-field">
|
|
12
|
+
<label for="lct-from">From</label>
|
|
13
|
+
<input id="lct-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
|
|
14
|
+
</div>
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
<div class="lct-field">
|
|
17
|
+
<label for="lct-to">To</label>
|
|
18
|
+
<input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
|
|
19
|
+
</div>
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
<div class="lct-field">
|
|
22
|
+
<label for="lct-provider">Provider</label>
|
|
23
|
+
<%= select_tag :provider,
|
|
24
|
+
options_for_select(provider_filter_options, params[:provider]),
|
|
25
|
+
include_blank: "All providers",
|
|
26
|
+
id: "lct-provider" %>
|
|
27
|
+
</div>
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
<div class="lct-field">
|
|
30
|
+
<label for="lct-model">Model</label>
|
|
31
|
+
<%= select_tag :model,
|
|
32
|
+
options_for_select(model_filter_options, params[:model]),
|
|
33
|
+
include_blank: "All models",
|
|
34
|
+
id: "lct-model" %>
|
|
35
|
+
</div>
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
<div class="lct-field">
|
|
38
|
+
<label for="lct-sort">Sort</label>
|
|
39
|
+
<select id="lct-sort" name="sort">
|
|
40
|
+
<option value="" <%= "selected" if @sort.blank? %>>Recent first</option>
|
|
41
|
+
<option value="expensive" <%= "selected" if @sort == "expensive" %>>Most expensive</option>
|
|
42
|
+
<option value="input" <%= "selected" if @sort == "input" %>>Largest input</option>
|
|
43
|
+
<option value="output" <%= "selected" if @sort == "output" %>>Largest output</option>
|
|
44
|
+
<% if @latency_available %>
|
|
45
|
+
<option value="slow" <%= "selected" if @sort == "slow" %>>Slowest</option>
|
|
46
|
+
<% end %>
|
|
47
|
+
<option value="unknown_pricing" <%= "selected" if @sort == "unknown_pricing" %>>Unknown pricing only</option>
|
|
48
|
+
</select>
|
|
49
|
+
</div>
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<option value="expensive" <%= "selected" if @sort == "expensive" %>>Most expensive</option>
|
|
39
|
-
<option value="input" <%= "selected" if @sort == "input" %>>Largest input</option>
|
|
40
|
-
<option value="output" <%= "selected" if @sort == "output" %>>Largest output</option>
|
|
41
|
-
<% if @latency_available %>
|
|
42
|
-
<option value="slow" <%= "selected" if @sort == "slow" %>>Slowest</option>
|
|
43
|
-
<% end %>
|
|
44
|
-
<option value="unknown_pricing" <%= "selected" if @sort == "unknown_pricing" %>>Unknown pricing only</option>
|
|
45
|
-
</select>
|
|
51
|
+
<div class="lct-filter-actions">
|
|
52
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
53
|
+
<%= link_to("Reset", calls_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
54
|
+
</div>
|
|
46
55
|
</div>
|
|
56
|
+
</form>
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
<label for="lct-per">Per page</label>
|
|
50
|
-
<input id="lct-per" type="number" name="per" min="1" max="200" value="<%= @page.per %>">
|
|
51
|
-
</div>
|
|
58
|
+
<%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: calls_path %>
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
<p class="lct-summary-row">
|
|
61
|
+
<span class="lct-pagination-per">
|
|
62
|
+
<span class="lct-pagination-per-label">Per page:</span>
|
|
63
|
+
<% LlmCostTracker::PaginationHelper::PER_PAGE_CHOICES.each do |choice| %>
|
|
64
|
+
<% if choice == @page.per %>
|
|
65
|
+
<span class="lct-pagination-per-option is-active" aria-current="true"><%= choice %></span>
|
|
66
|
+
<% else %>
|
|
67
|
+
<%= link_to choice, calls_path(current_query(per: choice, page: 1)), class: "lct-pagination-per-option" %>
|
|
68
|
+
<% end %>
|
|
69
|
+
<% end %>
|
|
70
|
+
</span>
|
|
71
|
+
</p>
|
|
72
|
+
<p class="lct-toolbar-note">CSV export is capped at <%= number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT) %> rows per request — narrow the date range to export larger slices.</p>
|
|
60
73
|
</section>
|
|
61
74
|
|
|
62
75
|
<% if @calls_count.zero? %>
|
|
63
76
|
<section class="lct-panel lct-empty">
|
|
64
|
-
<h2 class="lct-
|
|
65
|
-
<p class="lct-
|
|
77
|
+
<h2 class="lct-state-title">No matching calls</h2>
|
|
78
|
+
<p class="lct-state-copy">Tracked requests will appear here when they match the current filters.</p>
|
|
79
|
+
<div class="lct-state-actions">
|
|
80
|
+
<%= link_to "Clear filters", calls_path, class: "lct-button lct-button-secondary" %>
|
|
81
|
+
</div>
|
|
66
82
|
</section>
|
|
67
83
|
<% else %>
|
|
68
84
|
<section class="lct-panel">
|
|
69
85
|
<div class="lct-table-wrap">
|
|
70
|
-
<table class="lct-table lct-calls-table">
|
|
86
|
+
<table class="lct-table lct-table-compact lct-calls-table">
|
|
71
87
|
<thead>
|
|
72
88
|
<tr>
|
|
73
89
|
<th>Tracked At</th>
|
|
74
90
|
<th>Provider</th>
|
|
75
91
|
<th>Model</th>
|
|
76
|
-
<th>Input</th>
|
|
77
|
-
<th>Output</th>
|
|
78
|
-
<th>Total</th>
|
|
79
|
-
<th>Cost</th>
|
|
92
|
+
<th class="lct-num">Input</th>
|
|
93
|
+
<th class="lct-num">Output</th>
|
|
94
|
+
<th class="lct-num">Total</th>
|
|
95
|
+
<th class="lct-num">Cost</th>
|
|
80
96
|
<% if @latency_available %>
|
|
81
|
-
<th>Latency</th>
|
|
97
|
+
<th class="lct-num">Latency</th>
|
|
82
98
|
<% end %>
|
|
83
99
|
<th>Tags</th>
|
|
84
100
|
<th></th>
|
|
@@ -90,18 +106,16 @@
|
|
|
90
106
|
<tr>
|
|
91
107
|
<td class="lct-nowrap"><%= format_date(call.tracked_at) %></td>
|
|
92
108
|
<td><%= call.provider %></td>
|
|
93
|
-
<td><%= call.model %></td>
|
|
94
|
-
<td><%= format_tokens(call.input_tokens) %></td>
|
|
95
|
-
<td><%= format_tokens(call.output_tokens) %></td>
|
|
96
|
-
<td><%= format_tokens(call.total_tokens) %></td>
|
|
97
|
-
<td><%= optional_money(call.total_cost) %></td>
|
|
109
|
+
<td><code class="lct-code"><%= call.model %></code></td>
|
|
110
|
+
<td class="lct-num"><%= format_tokens(call.input_tokens) %></td>
|
|
111
|
+
<td class="lct-num"><%= format_tokens(call.output_tokens) %></td>
|
|
112
|
+
<td class="lct-num"><%= format_tokens(call.total_tokens) %></td>
|
|
113
|
+
<td class="lct-num<%= ' lct-num-muted' if call.total_cost.nil? %>"><%= optional_money(call.total_cost) %></td>
|
|
98
114
|
<% if @latency_available %>
|
|
99
|
-
<td><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
|
|
115
|
+
<td class="lct-num<%= ' lct-num-muted' if call.latency_ms.nil? %>"><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
|
|
100
116
|
<% end %>
|
|
101
|
-
<td>
|
|
102
|
-
|
|
103
|
-
</td>
|
|
104
|
-
<td><%= link_to "Details", call_path(call), class: "lct-button lct-button-secondary" %></td>
|
|
117
|
+
<td><%= render "llm_cost_tracker/shared/tag_chips", tags: tags %></td>
|
|
118
|
+
<td><%= link_to "Details", call_path(call), class: "lct-button lct-button-secondary lct-button-compact" %></td>
|
|
105
119
|
</tr>
|
|
106
120
|
<% end %>
|
|
107
121
|
</tbody>
|
|
@@ -111,17 +125,35 @@
|
|
|
111
125
|
<nav class="lct-pagination" aria-label="Pagination">
|
|
112
126
|
<% first_row = @page.offset + 1 %>
|
|
113
127
|
<% last_row = [@page.offset + @calls.length, @calls_count].min %>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
128
|
+
<% total_pages = @page.total_pages(@calls_count) %>
|
|
129
|
+
|
|
130
|
+
<div class="lct-pagination-info">
|
|
131
|
+
<span>Showing <strong><%= number(first_row) %></strong> to <strong><%= number(last_row) %></strong> of <strong><%= number(@calls_count) %></strong> results</span>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div class="lct-pagination-nav" role="group" aria-label="Page navigation">
|
|
118
135
|
<% if @page.prev_page? %>
|
|
119
|
-
<%= link_to "
|
|
136
|
+
<%= link_to "‹", calls_path(current_query(page: @page.page - 1, per: @page.per)), class: "lct-page-link lct-page-arrow", rel: "prev", aria: { label: "Previous page" } %>
|
|
137
|
+
<% else %>
|
|
138
|
+
<span class="lct-page-link lct-page-arrow is-disabled" aria-disabled="true" aria-label="Previous page">‹</span>
|
|
120
139
|
<% end %>
|
|
140
|
+
|
|
141
|
+
<% pagination_page_items(@page.page, total_pages).each do |item| %>
|
|
142
|
+
<% if item == :gap %>
|
|
143
|
+
<span class="lct-page-gap" aria-hidden="true">…</span>
|
|
144
|
+
<% elsif item == @page.page %>
|
|
145
|
+
<span class="lct-page-link is-current" aria-current="page"><%= item %></span>
|
|
146
|
+
<% else %>
|
|
147
|
+
<%= link_to item, calls_path(current_query(page: item, per: @page.per)), class: "lct-page-link", aria: { label: "Go to page #{item}" } %>
|
|
148
|
+
<% end %>
|
|
149
|
+
<% end %>
|
|
150
|
+
|
|
121
151
|
<% if @page.next_page?(@calls_count) %>
|
|
122
|
-
<%= link_to "
|
|
152
|
+
<%= link_to "›", calls_path(current_query(page: @page.page + 1, per: @page.per)), class: "lct-page-link lct-page-arrow", rel: "next", aria: { label: "Next page" } %>
|
|
153
|
+
<% else %>
|
|
154
|
+
<span class="lct-page-link lct-page-arrow is-disabled" aria-disabled="true" aria-label="Next page">›</span>
|
|
123
155
|
<% end %>
|
|
124
|
-
</
|
|
156
|
+
</div>
|
|
125
157
|
</nav>
|
|
126
158
|
</section>
|
|
127
159
|
<% end %>
|
|
@@ -1,6 +1,63 @@
|
|
|
1
|
+
<% token_segments = [
|
|
2
|
+
{ label: "Input", value: @call.input_tokens, formatted_value: format_tokens(@call.input_tokens), css_class: "lct-stack-fill-input" },
|
|
3
|
+
{ label: "Output", value: @call.output_tokens, formatted_value: format_tokens(@call.output_tokens), css_class: "lct-stack-fill-output" }
|
|
4
|
+
] %>
|
|
5
|
+
<% cost_segments = [
|
|
6
|
+
{ label: "Input", value: @call.input_cost, formatted_value: optional_money(@call.input_cost), css_class: "lct-stack-fill-input" },
|
|
7
|
+
{ label: "Output", value: @call.output_cost, formatted_value: optional_money(@call.output_cost), css_class: "lct-stack-fill-output" }
|
|
8
|
+
] %>
|
|
9
|
+
|
|
1
10
|
<section class="lct-panel">
|
|
2
11
|
<p class="lct-muted"><%= link_to "Back to calls", calls_path %></p>
|
|
3
|
-
<
|
|
12
|
+
<div class="lct-call-hero">
|
|
13
|
+
<div>
|
|
14
|
+
<h2 class="lct-section-title lct-call-title">Call #<%= @call.id %></h2>
|
|
15
|
+
<p class="lct-call-subtitle">
|
|
16
|
+
<code class="lct-code"><%= @call.provider %></code>
|
|
17
|
+
<span>·</span>
|
|
18
|
+
<code class="lct-code"><%= @call.model %></code>
|
|
19
|
+
<span>·</span>
|
|
20
|
+
<span><%= format_date(@call.tracked_at) %></span>
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="lct-call-summary">
|
|
25
|
+
<div class="lct-call-summary-item">
|
|
26
|
+
<span class="lct-call-summary-label">Pricing</span>
|
|
27
|
+
<strong><%= pricing_status(@call) %></strong>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="lct-call-summary-item">
|
|
30
|
+
<span class="lct-call-summary-label">Total cost</span>
|
|
31
|
+
<strong><%= optional_money(@call.total_cost) %></strong>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="lct-call-summary-item">
|
|
34
|
+
<span class="lct-call-summary-label">Total tokens</span>
|
|
35
|
+
<strong><%= format_tokens(@call.total_tokens) %></strong>
|
|
36
|
+
</div>
|
|
37
|
+
<% if @latency_available %>
|
|
38
|
+
<div class="lct-call-summary-item">
|
|
39
|
+
<span class="lct-call-summary-label">Latency</span>
|
|
40
|
+
<strong><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></strong>
|
|
41
|
+
</div>
|
|
42
|
+
<% end %>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="lct-call-breakdown-grid">
|
|
47
|
+
<section class="lct-call-breakdown">
|
|
48
|
+
<h3 class="lct-call-breakdown-title">Token Mix</h3>
|
|
49
|
+
<%= render "llm_cost_tracker/shared/metric_stack", segments: token_segments, empty_message: "No token data for this call." %>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<section class="lct-call-breakdown">
|
|
53
|
+
<h3 class="lct-call-breakdown-title">Cost Mix</h3>
|
|
54
|
+
<% if @call.total_cost.nil? %>
|
|
55
|
+
<p class="lct-call-breakdown-empty">Pricing not available for this call.</p>
|
|
56
|
+
<% else %>
|
|
57
|
+
<%= render "llm_cost_tracker/shared/metric_stack", segments: cost_segments, empty_message: "No cost breakdown for this call." %>
|
|
58
|
+
<% end %>
|
|
59
|
+
</section>
|
|
60
|
+
</div>
|
|
4
61
|
|
|
5
62
|
<div class="lct-detail-grid">
|
|
6
63
|
<dl class="lct-dl">
|