llm_cost_tracker 0.1.4 → 0.2.0.alpha2
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 +58 -91
- data/PLAN_0.2.md +488 -0
- data/README.md +140 -320
- data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
- data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
- data/app/services/llm_cost_tracker/pagination.rb +59 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
- data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
- data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
- data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
- data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
- data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
- data/config/routes.rb +10 -0
- data/lib/llm_cost_tracker/budget.rb +16 -38
- data/lib/llm_cost_tracker/configuration.rb +3 -1
- data/lib/llm_cost_tracker/cost.rb +1 -3
- data/lib/llm_cost_tracker/engine.rb +13 -0
- data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
- data/lib/llm_cost_tracker/errors.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +1 -3
- data/lib/llm_cost_tracker/event_metadata.rb +9 -18
- data/lib/llm_cost_tracker/llm_api_call.rb +4 -17
- data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
- data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
- data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
- data/lib/llm_cost_tracker/parsers/base.rb +3 -8
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
- data/lib/llm_cost_tracker/period_grouping.rb +68 -0
- data/lib/llm_cost_tracker/price_registry.rb +22 -30
- data/lib/llm_cost_tracker/pricing.rb +10 -19
- data/lib/llm_cost_tracker/report.rb +4 -4
- data/lib/llm_cost_tracker/report_data.rb +21 -24
- data/lib/llm_cost_tracker/report_formatter.rb +4 -2
- data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
- data/lib/llm_cost_tracker/tag_key.rb +16 -0
- data/lib/llm_cost_tracker/tracker.rb +35 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +3 -6
- data/llm_cost_tracker.gemspec +13 -9
- metadata +91 -20
- data/.rubocop.yml +0 -44
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
- data/lib/llm_cost_tracker/storage/backends.rb +0 -26
- data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
- data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
- data/lib/llm_cost_tracker/value_object.rb +0 -45
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
2
|
+
<h2 class="lct-section-title">Calls</h2>
|
|
3
|
+
<form class="lct-filters" action="<%= calls_path %>" method="get">
|
|
4
|
+
<div class="lct-field">
|
|
5
|
+
<label for="lct-from">From</label>
|
|
6
|
+
<input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="lct-field">
|
|
10
|
+
<label for="lct-to">To</label>
|
|
11
|
+
<input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="lct-field">
|
|
15
|
+
<label for="lct-provider">Provider</label>
|
|
16
|
+
<input id="lct-provider" type="text" name="provider" value="<%= params[:provider] %>">
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="lct-field">
|
|
20
|
+
<label for="lct-model">Model</label>
|
|
21
|
+
<input id="lct-model" type="text" name="model" value="<%= params[:model] %>">
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="lct-field">
|
|
25
|
+
<label for="lct-tag-key">Tag key</label>
|
|
26
|
+
<input id="lct-tag-key" type="text" name="tag_key" value="<%= params[:tag_key] %>">
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="lct-field">
|
|
30
|
+
<label for="lct-tag-value">Tag value</label>
|
|
31
|
+
<input id="lct-tag-value" type="text" name="tag_value" value="<%= params[:tag_value] %>">
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="lct-field">
|
|
35
|
+
<label for="lct-sort">Sort / filter</label>
|
|
36
|
+
<select id="lct-sort" name="sort">
|
|
37
|
+
<option value="" <%= "selected" if @sort.blank? %>>Recent first</option>
|
|
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>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="lct-field">
|
|
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>
|
|
52
|
+
|
|
53
|
+
<div class="lct-button-row">
|
|
54
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
55
|
+
<%= link_to "Reset", calls_path, class: "lct-button lct-button-secondary" %>
|
|
56
|
+
<%= link_to "Export CSV", calls_path(current_query(format: :csv)), class: "lct-button lct-button-secondary" %>
|
|
57
|
+
</div>
|
|
58
|
+
</form>
|
|
59
|
+
<p class="lct-muted">CSV export is capped at <%= number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT) %> rows per request — narrow the date range to export larger slices.</p>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<% if @calls_count.zero? %>
|
|
63
|
+
<section class="lct-panel lct-empty">
|
|
64
|
+
<h2 class="lct-section-title">No matching calls</h2>
|
|
65
|
+
<p class="lct-muted">Tracked requests will appear here when they match the current filters.</p>
|
|
66
|
+
</section>
|
|
67
|
+
<% else %>
|
|
68
|
+
<section class="lct-panel">
|
|
69
|
+
<div class="lct-table-wrap">
|
|
70
|
+
<table class="lct-table lct-calls-table">
|
|
71
|
+
<thead>
|
|
72
|
+
<tr>
|
|
73
|
+
<th>Tracked At</th>
|
|
74
|
+
<th>Provider</th>
|
|
75
|
+
<th>Model</th>
|
|
76
|
+
<th>Input</th>
|
|
77
|
+
<th>Output</th>
|
|
78
|
+
<th>Total</th>
|
|
79
|
+
<th>Cost</th>
|
|
80
|
+
<% if @latency_available %>
|
|
81
|
+
<th>Latency</th>
|
|
82
|
+
<% end %>
|
|
83
|
+
<th>Tags</th>
|
|
84
|
+
<th></th>
|
|
85
|
+
</tr>
|
|
86
|
+
</thead>
|
|
87
|
+
<tbody>
|
|
88
|
+
<% @calls.each do |call| %>
|
|
89
|
+
<% tags = call.parsed_tags %>
|
|
90
|
+
<tr>
|
|
91
|
+
<td class="lct-nowrap"><%= format_date(call.tracked_at) %></td>
|
|
92
|
+
<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>
|
|
98
|
+
<% if @latency_available %>
|
|
99
|
+
<td><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
|
|
100
|
+
<% end %>
|
|
101
|
+
<td>
|
|
102
|
+
<span class="lct-tags" title="<%= safe_json(tags) %>"><%= tags_summary(tags) %></span>
|
|
103
|
+
</td>
|
|
104
|
+
<td><%= link_to "Details", call_path(call), class: "lct-button lct-button-secondary" %></td>
|
|
105
|
+
</tr>
|
|
106
|
+
<% end %>
|
|
107
|
+
</tbody>
|
|
108
|
+
</table>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<nav class="lct-pagination" aria-label="Pagination">
|
|
112
|
+
<% first_row = @page.offset + 1 %>
|
|
113
|
+
<% last_row = [@page.offset + @calls.length, @calls_count].min %>
|
|
114
|
+
<span class="lct-muted">
|
|
115
|
+
<%= number(first_row) %>-<%= number(last_row) %> of <%= number(@calls_count) %>
|
|
116
|
+
</span>
|
|
117
|
+
<span>
|
|
118
|
+
<% if @page.prev_page? %>
|
|
119
|
+
<%= link_to "Previous", calls_path(current_query(page: @page.page - 1, per: @page.per)), class: "lct-button lct-button-secondary" %>
|
|
120
|
+
<% end %>
|
|
121
|
+
<% if @page.next_page?(@calls_count) %>
|
|
122
|
+
<%= link_to "Next", calls_path(current_query(page: @page.page + 1, per: @page.per)), class: "lct-button lct-button-secondary" %>
|
|
123
|
+
<% end %>
|
|
124
|
+
</span>
|
|
125
|
+
</nav>
|
|
126
|
+
</section>
|
|
127
|
+
<% end %>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
2
|
+
<p class="lct-muted"><%= link_to "Back to calls", calls_path %></p>
|
|
3
|
+
<h2 class="lct-section-title">Call #<%= @call.id %></h2>
|
|
4
|
+
|
|
5
|
+
<div class="lct-detail-grid">
|
|
6
|
+
<dl class="lct-dl">
|
|
7
|
+
<dt>Tracked At</dt>
|
|
8
|
+
<dd><%= format_date(@call.tracked_at) %></dd>
|
|
9
|
+
|
|
10
|
+
<dt>Provider</dt>
|
|
11
|
+
<dd><%= @call.provider %></dd>
|
|
12
|
+
|
|
13
|
+
<dt>Model</dt>
|
|
14
|
+
<dd><%= @call.model %></dd>
|
|
15
|
+
|
|
16
|
+
<dt>Pricing Status</dt>
|
|
17
|
+
<dd><%= pricing_status(@call) %></dd>
|
|
18
|
+
|
|
19
|
+
<% if @call.has_attribute?("created_at") %>
|
|
20
|
+
<dt>Created At</dt>
|
|
21
|
+
<dd><%= format_date(@call.created_at) %></dd>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<% if @call.has_attribute?("updated_at") %>
|
|
25
|
+
<dt>Updated At</dt>
|
|
26
|
+
<dd><%= format_date(@call.updated_at) %></dd>
|
|
27
|
+
<% end %>
|
|
28
|
+
</dl>
|
|
29
|
+
|
|
30
|
+
<dl class="lct-dl">
|
|
31
|
+
<dt>Input Tokens</dt>
|
|
32
|
+
<dd><%= format_tokens(@call.input_tokens) %></dd>
|
|
33
|
+
|
|
34
|
+
<dt>Output Tokens</dt>
|
|
35
|
+
<dd><%= format_tokens(@call.output_tokens) %></dd>
|
|
36
|
+
|
|
37
|
+
<dt>Total Tokens</dt>
|
|
38
|
+
<dd><%= format_tokens(@call.total_tokens) %></dd>
|
|
39
|
+
|
|
40
|
+
<dt>Input Cost</dt>
|
|
41
|
+
<dd><%= optional_money(@call.input_cost) %></dd>
|
|
42
|
+
|
|
43
|
+
<dt>Output Cost</dt>
|
|
44
|
+
<dd><%= optional_money(@call.output_cost) %></dd>
|
|
45
|
+
|
|
46
|
+
<dt>Total Cost</dt>
|
|
47
|
+
<dd><%= optional_money(@call.total_cost) %></dd>
|
|
48
|
+
|
|
49
|
+
<% if @latency_available %>
|
|
50
|
+
<dt>Latency</dt>
|
|
51
|
+
<dd><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></dd>
|
|
52
|
+
<% end %>
|
|
53
|
+
</dl>
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<section class="lct-panel">
|
|
58
|
+
<h2 class="lct-section-title">Tags</h2>
|
|
59
|
+
<pre class="lct-pre"><%= safe_json(@tags) %></pre>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<% if @metadata_available %>
|
|
63
|
+
<section class="lct-panel">
|
|
64
|
+
<h2 class="lct-section-title">Metadata</h2>
|
|
65
|
+
<pre class="lct-pre"><%= safe_json(@metadata) %></pre>
|
|
66
|
+
</section>
|
|
67
|
+
<% end %>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<% if @stats.total_calls.zero? %>
|
|
2
|
+
<section class="lct-panel lct-empty">
|
|
3
|
+
<h2 class="lct-section-title">No LLM calls yet</h2>
|
|
4
|
+
<p class="lct-muted">Tracked requests will appear here after your application records its first LLM API call.</p>
|
|
5
|
+
</section>
|
|
6
|
+
<% else %>
|
|
7
|
+
<section class="lct-grid lct-stats">
|
|
8
|
+
<article class="lct-stat">
|
|
9
|
+
<p class="lct-stat-label">Total spend</p>
|
|
10
|
+
<p class="lct-stat-value"><%= money(@stats.total_cost) %></p>
|
|
11
|
+
<% badge = delta_badge(@stats.cost_delta_percent) %>
|
|
12
|
+
<p class="<%= badge[:css_class] %>"><%= badge[:text] %></p>
|
|
13
|
+
</article>
|
|
14
|
+
|
|
15
|
+
<article class="lct-stat">
|
|
16
|
+
<p class="lct-stat-label">Calls</p>
|
|
17
|
+
<p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
|
|
18
|
+
<% badge = delta_badge(@stats.calls_delta_percent, mode: :neutral) %>
|
|
19
|
+
<p class="<%= badge[:css_class] %>"><%= badge[:text] %></p>
|
|
20
|
+
</article>
|
|
21
|
+
|
|
22
|
+
<article class="lct-stat">
|
|
23
|
+
<p class="lct-stat-label">Avg cost / call</p>
|
|
24
|
+
<p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
|
|
25
|
+
</article>
|
|
26
|
+
|
|
27
|
+
<% if @stats.average_latency_ms %>
|
|
28
|
+
<article class="lct-stat">
|
|
29
|
+
<p class="lct-stat-label">Avg latency</p>
|
|
30
|
+
<p class="lct-stat-value"><%= number(@stats.average_latency_ms.round) %>ms</p>
|
|
31
|
+
</article>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% if @stats.unknown_pricing_count.positive? %>
|
|
35
|
+
<article class="lct-stat">
|
|
36
|
+
<p class="lct-stat-label">Unknown pricing</p>
|
|
37
|
+
<p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
|
|
38
|
+
<p class="lct-stat-sub">
|
|
39
|
+
<%= percent(coverage_percent(@stats.unknown_pricing_count, @stats.total_calls)) %> of calls ·
|
|
40
|
+
<%= link_to "Review →", data_quality_path %>
|
|
41
|
+
</p>
|
|
42
|
+
</article>
|
|
43
|
+
<% end %>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<% if @stats.monthly_budget_status %>
|
|
47
|
+
<% budget = @stats.monthly_budget_status %>
|
|
48
|
+
<section class="lct-panel">
|
|
49
|
+
<h2 class="lct-section-title">Monthly Budget</h2>
|
|
50
|
+
<p class="lct-muted">Current-month spend across <strong>all</strong> calls — overview filters do not apply. Soft limit: blocking is not atomic under concurrency.</p>
|
|
51
|
+
<table class="lct-table">
|
|
52
|
+
<tbody>
|
|
53
|
+
<tr>
|
|
54
|
+
<th>Spent</th>
|
|
55
|
+
<td><%= money(budget[:spent]) %></td>
|
|
56
|
+
</tr>
|
|
57
|
+
<tr>
|
|
58
|
+
<th>Budget</th>
|
|
59
|
+
<td><%= money(budget[:budget]) %></td>
|
|
60
|
+
</tr>
|
|
61
|
+
<tr>
|
|
62
|
+
<th>Used</th>
|
|
63
|
+
<td><%= percent(budget[:percent_used]) %></td>
|
|
64
|
+
</tr>
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
<%= render "llm_cost_tracker/shared/bar", value: budget[:percent_used], max: 100.0, variant: "budget" %>
|
|
68
|
+
</section>
|
|
69
|
+
<% end %>
|
|
70
|
+
|
|
71
|
+
<section class="lct-panel">
|
|
72
|
+
<h2 class="lct-section-title">Daily Spend</h2>
|
|
73
|
+
<% max_cost = @time_series.map { |point| point[:cost] }.max.to_f %>
|
|
74
|
+
<table class="lct-table">
|
|
75
|
+
<thead>
|
|
76
|
+
<tr>
|
|
77
|
+
<th>Day</th>
|
|
78
|
+
<th>Spend</th>
|
|
79
|
+
<th>Trend</th>
|
|
80
|
+
</tr>
|
|
81
|
+
</thead>
|
|
82
|
+
<tbody>
|
|
83
|
+
<% @time_series.each do |point| %>
|
|
84
|
+
<tr>
|
|
85
|
+
<td><%= point[:label] %></td>
|
|
86
|
+
<td><%= money(point[:cost]) %></td>
|
|
87
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: point[:cost], max: max_cost %></td>
|
|
88
|
+
</tr>
|
|
89
|
+
<% end %>
|
|
90
|
+
</tbody>
|
|
91
|
+
</table>
|
|
92
|
+
</section>
|
|
93
|
+
|
|
94
|
+
<% if @providers.any? %>
|
|
95
|
+
<section class="lct-panel">
|
|
96
|
+
<h2 class="lct-section-title">By Provider</h2>
|
|
97
|
+
<table class="lct-table">
|
|
98
|
+
<thead>
|
|
99
|
+
<tr>
|
|
100
|
+
<th>Provider</th>
|
|
101
|
+
<th>Calls</th>
|
|
102
|
+
<th>Spend</th>
|
|
103
|
+
<th>Share</th>
|
|
104
|
+
</tr>
|
|
105
|
+
</thead>
|
|
106
|
+
<tbody>
|
|
107
|
+
<% @providers.each do |row| %>
|
|
108
|
+
<tr>
|
|
109
|
+
<td><%= row.provider %></td>
|
|
110
|
+
<td><%= number(row.calls) %></td>
|
|
111
|
+
<td><%= money(row.total_cost) %></td>
|
|
112
|
+
<td><%= percent(row.share_percent) %></td>
|
|
113
|
+
</tr>
|
|
114
|
+
<% end %>
|
|
115
|
+
</tbody>
|
|
116
|
+
</table>
|
|
117
|
+
</section>
|
|
118
|
+
<% end %>
|
|
119
|
+
|
|
120
|
+
<section class="lct-panel">
|
|
121
|
+
<h2 class="lct-section-title">Top Models</h2>
|
|
122
|
+
<table class="lct-table">
|
|
123
|
+
<thead>
|
|
124
|
+
<tr>
|
|
125
|
+
<th>Provider</th>
|
|
126
|
+
<th>Model</th>
|
|
127
|
+
<th>Calls</th>
|
|
128
|
+
<th>Spend</th>
|
|
129
|
+
<th>Avg cost / call</th>
|
|
130
|
+
</tr>
|
|
131
|
+
</thead>
|
|
132
|
+
<tbody>
|
|
133
|
+
<% @top_models.each do |row| %>
|
|
134
|
+
<tr>
|
|
135
|
+
<td><%= row.provider %></td>
|
|
136
|
+
<td><%= row.model %></td>
|
|
137
|
+
<td><%= number(row.calls) %></td>
|
|
138
|
+
<td><%= money(row.total_cost) %></td>
|
|
139
|
+
<td><%= money(row.average_cost_per_call) %></td>
|
|
140
|
+
</tr>
|
|
141
|
+
<% end %>
|
|
142
|
+
</tbody>
|
|
143
|
+
</table>
|
|
144
|
+
</section>
|
|
145
|
+
<% end %>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<% total = @stats.total_calls %>
|
|
2
|
+
|
|
3
|
+
<% if total.zero? %>
|
|
4
|
+
<section class="lct-panel lct-empty">
|
|
5
|
+
<h2 class="lct-section-title">No data yet</h2>
|
|
6
|
+
<p class="lct-muted">Data quality metrics will appear here once calls are recorded.</p>
|
|
7
|
+
</section>
|
|
8
|
+
<% else %>
|
|
9
|
+
<section class="lct-grid lct-stats">
|
|
10
|
+
<article class="lct-stat">
|
|
11
|
+
<p class="lct-stat-label">Total calls</p>
|
|
12
|
+
<p class="lct-stat-value"><%= number(total) %></p>
|
|
13
|
+
</article>
|
|
14
|
+
|
|
15
|
+
<article class="lct-stat">
|
|
16
|
+
<p class="lct-stat-label">Unknown pricing</p>
|
|
17
|
+
<p class="lct-stat-value"><%= number(@stats.unknown_pricing_count) %></p>
|
|
18
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(@stats.unknown_pricing_count, total)) %> of calls</p>
|
|
19
|
+
</article>
|
|
20
|
+
|
|
21
|
+
<article class="lct-stat">
|
|
22
|
+
<p class="lct-stat-label">Calls without tags</p>
|
|
23
|
+
<p class="lct-stat-value"><%= number(@stats.untagged_calls_count) %></p>
|
|
24
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(@stats.untagged_calls_count, total)) %> of calls</p>
|
|
25
|
+
</article>
|
|
26
|
+
|
|
27
|
+
<% if @stats.latency_column_present %>
|
|
28
|
+
<article class="lct-stat">
|
|
29
|
+
<p class="lct-stat-label">Missing latency</p>
|
|
30
|
+
<p class="lct-stat-value"><%= number(@stats.missing_latency_count) %></p>
|
|
31
|
+
<p class="lct-stat-sub"><%= percent(coverage_percent(@stats.missing_latency_count, total)) %> of calls</p>
|
|
32
|
+
</article>
|
|
33
|
+
<% end %>
|
|
34
|
+
</section>
|
|
35
|
+
|
|
36
|
+
<% unless @stats.unknown_pricing_by_model.empty? %>
|
|
37
|
+
<section class="lct-panel">
|
|
38
|
+
<h2 class="lct-section-title">Unknown Pricing by Model</h2>
|
|
39
|
+
<p class="lct-muted">
|
|
40
|
+
These models have no pricing configured. Token counts are recorded but cost is <code class="lct-code">nil</code>.
|
|
41
|
+
Add entries to <code class="lct-code">pricing_overrides</code> or <code class="lct-code">prices_file</code> to fix.
|
|
42
|
+
</p>
|
|
43
|
+
<div class="lct-table-wrap">
|
|
44
|
+
<table class="lct-table">
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th>Model</th>
|
|
48
|
+
<th>Calls without cost</th>
|
|
49
|
+
<th>Share of total</th>
|
|
50
|
+
</tr>
|
|
51
|
+
</thead>
|
|
52
|
+
<tbody>
|
|
53
|
+
<% @stats.unknown_pricing_by_model.each do |model, count| %>
|
|
54
|
+
<tr>
|
|
55
|
+
<td><code class="lct-code"><%= model %></code></td>
|
|
56
|
+
<td><%= number(count) %></td>
|
|
57
|
+
<td><%= percent(total.positive? ? (count.to_f / total) * 100 : 0) %></td>
|
|
58
|
+
</tr>
|
|
59
|
+
<% end %>
|
|
60
|
+
</tbody>
|
|
61
|
+
</table>
|
|
62
|
+
</div>
|
|
63
|
+
<p class="lct-muted" style="margin-top:12px">
|
|
64
|
+
<%= link_to "View all unknown pricing calls →", calls_path(sort: "unknown_pricing") %>
|
|
65
|
+
</p>
|
|
66
|
+
</section>
|
|
67
|
+
<% end %>
|
|
68
|
+
|
|
69
|
+
<section class="lct-panel">
|
|
70
|
+
<h2 class="lct-section-title">Coverage Summary</h2>
|
|
71
|
+
<table class="lct-table">
|
|
72
|
+
<thead>
|
|
73
|
+
<tr>
|
|
74
|
+
<th>Dimension</th>
|
|
75
|
+
<th>Coverage</th>
|
|
76
|
+
<th>Calls with data</th>
|
|
77
|
+
<th>Visual</th>
|
|
78
|
+
</tr>
|
|
79
|
+
</thead>
|
|
80
|
+
<tbody>
|
|
81
|
+
<% cost_coverage = coverage_percent(total - @stats.unknown_pricing_count, total) %>
|
|
82
|
+
<tr>
|
|
83
|
+
<td>Cost (pricing known)</td>
|
|
84
|
+
<td><%= percent(cost_coverage) %></td>
|
|
85
|
+
<td><%= number(total - @stats.unknown_pricing_count) %></td>
|
|
86
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: cost_coverage, max: 100.0 %></td>
|
|
87
|
+
</tr>
|
|
88
|
+
|
|
89
|
+
<% tag_coverage = coverage_percent(total - @stats.untagged_calls_count, total) %>
|
|
90
|
+
<tr>
|
|
91
|
+
<td>Tags (at least one tag)</td>
|
|
92
|
+
<td><%= percent(tag_coverage) %></td>
|
|
93
|
+
<td><%= number(total - @stats.untagged_calls_count) %></td>
|
|
94
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: tag_coverage, max: 100.0 %></td>
|
|
95
|
+
</tr>
|
|
96
|
+
|
|
97
|
+
<% if @stats.latency_column_present %>
|
|
98
|
+
<% latency_coverage = coverage_percent(total - @stats.missing_latency_count, total) %>
|
|
99
|
+
<tr>
|
|
100
|
+
<td>Latency</td>
|
|
101
|
+
<td><%= percent(latency_coverage) %></td>
|
|
102
|
+
<td><%= number(total - @stats.missing_latency_count) %></td>
|
|
103
|
+
<td><%= render "llm_cost_tracker/shared/bar", value: latency_coverage, max: 100.0 %></td>
|
|
104
|
+
</tr>
|
|
105
|
+
<% end %>
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</section>
|
|
109
|
+
<% end %>
|
|
110
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
2
|
+
<h2>Database unavailable</h2>
|
|
3
|
+
<p class="lct-muted">
|
|
4
|
+
llm_cost_tracker could not read the <span class="lct-code">llm_api_calls</span> table.
|
|
5
|
+
Check that ActiveRecord is connected, then run
|
|
6
|
+
<span class="lct-code">rails generate llm_cost_tracker:install</span> and migrate your database.
|
|
7
|
+
</p>
|
|
8
|
+
</section>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
2
|
+
<h2 class="lct-section-title">Models</h2>
|
|
3
|
+
<form class="lct-filters" action="<%= models_path %>" method="get">
|
|
4
|
+
<div class="lct-field">
|
|
5
|
+
<label for="lct-from">From</label>
|
|
6
|
+
<input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="lct-field">
|
|
10
|
+
<label for="lct-to">To</label>
|
|
11
|
+
<input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="lct-field">
|
|
15
|
+
<label for="lct-provider">Provider</label>
|
|
16
|
+
<input id="lct-provider" type="text" name="provider" value="<%= params[:provider] %>">
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="lct-field">
|
|
20
|
+
<label for="lct-model">Model</label>
|
|
21
|
+
<input id="lct-model" type="text" name="model" value="<%= params[:model] %>">
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="lct-field">
|
|
25
|
+
<label for="lct-tag-key">Tag key</label>
|
|
26
|
+
<input id="lct-tag-key" type="text" name="tag_key" value="<%= params[:tag_key] %>">
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div class="lct-field">
|
|
30
|
+
<label for="lct-tag-value">Tag value</label>
|
|
31
|
+
<input id="lct-tag-value" type="text" name="tag_value" value="<%= params[:tag_value] %>">
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="lct-field">
|
|
35
|
+
<label for="lct-sort">Sort by</label>
|
|
36
|
+
<select id="lct-sort" name="sort">
|
|
37
|
+
<option value="cost" <%= "selected" if @sort.blank? || @sort == "cost" %>>Total spend</option>
|
|
38
|
+
<option value="calls" <%= "selected" if @sort == "calls" %>>Call volume</option>
|
|
39
|
+
<option value="avg_cost" <%= "selected" if @sort == "avg_cost" %>>Avg cost / call</option>
|
|
40
|
+
<% if @latency_available %>
|
|
41
|
+
<option value="latency" <%= "selected" if @sort == "latency" %>>Avg latency</option>
|
|
42
|
+
<% end %>
|
|
43
|
+
</select>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="lct-button-row">
|
|
47
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
48
|
+
<%= link_to "Reset", models_path, class: "lct-button lct-button-secondary" %>
|
|
49
|
+
</div>
|
|
50
|
+
</form>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<% if @rows.empty? %>
|
|
54
|
+
<section class="lct-panel lct-empty">
|
|
55
|
+
<h2 class="lct-section-title">No models</h2>
|
|
56
|
+
<p class="lct-muted">Tracked models will appear here when they match the current filters.</p>
|
|
57
|
+
</section>
|
|
58
|
+
<% else %>
|
|
59
|
+
<section class="lct-panel">
|
|
60
|
+
<div class="lct-table-wrap">
|
|
61
|
+
<table class="lct-table">
|
|
62
|
+
<thead>
|
|
63
|
+
<tr>
|
|
64
|
+
<th>Provider</th>
|
|
65
|
+
<th>Model</th>
|
|
66
|
+
<th>Calls</th>
|
|
67
|
+
<th>Input</th>
|
|
68
|
+
<th>Output</th>
|
|
69
|
+
<th>Total cost</th>
|
|
70
|
+
<th>Avg cost / call</th>
|
|
71
|
+
<% if @latency_available %>
|
|
72
|
+
<th>Avg latency</th>
|
|
73
|
+
<% end %>
|
|
74
|
+
</tr>
|
|
75
|
+
</thead>
|
|
76
|
+
<tbody>
|
|
77
|
+
<% @rows.each do |row| %>
|
|
78
|
+
<tr>
|
|
79
|
+
<td><%= row.provider %></td>
|
|
80
|
+
<td><%= row.model %></td>
|
|
81
|
+
<td><%= number(row.calls) %></td>
|
|
82
|
+
<td><%= format_tokens(row.input_tokens) %></td>
|
|
83
|
+
<td><%= format_tokens(row.output_tokens) %></td>
|
|
84
|
+
<td><%= money(row.total_cost) %></td>
|
|
85
|
+
<td><%= money(row.average_cost_per_call) %></td>
|
|
86
|
+
<% if @latency_available %>
|
|
87
|
+
<td><%= row.average_latency_ms ? "#{number(row.average_latency_ms.round)}ms" : "n/a" %></td>
|
|
88
|
+
<% end %>
|
|
89
|
+
</tr>
|
|
90
|
+
<% end %>
|
|
91
|
+
</tbody>
|
|
92
|
+
</table>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
<% end %>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<% if @rows.empty? %>
|
|
2
|
+
<section class="lct-panel lct-empty">
|
|
3
|
+
<h2 class="lct-section-title">No tag keys found</h2>
|
|
4
|
+
<p class="lct-muted">No calls with tags have been recorded yet. Tag keys will appear here once data is available.</p>
|
|
5
|
+
</section>
|
|
6
|
+
<% else %>
|
|
7
|
+
<section class="lct-panel">
|
|
8
|
+
<h2 class="lct-section-title">Tag Keys</h2>
|
|
9
|
+
<p class="lct-muted">All tag keys present in the dataset. Click a key to explore spend and call distribution by value.</p>
|
|
10
|
+
<div class="lct-table-wrap">
|
|
11
|
+
<table class="lct-table">
|
|
12
|
+
<thead>
|
|
13
|
+
<tr>
|
|
14
|
+
<th>Tag Key</th>
|
|
15
|
+
<th>Calls with this key</th>
|
|
16
|
+
<th>Distinct values</th>
|
|
17
|
+
<th></th>
|
|
18
|
+
</tr>
|
|
19
|
+
</thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<% @rows.each do |row| %>
|
|
22
|
+
<tr>
|
|
23
|
+
<td><code class="lct-code"><%= row.key %></code></td>
|
|
24
|
+
<td><%= number(row.calls_count) %></td>
|
|
25
|
+
<td><%= number(row.distinct_values) %></td>
|
|
26
|
+
<td><%= link_to "Breakdown →", tag_path(row.key), class: "lct-button lct-button-secondary" %></td>
|
|
27
|
+
</tr>
|
|
28
|
+
<% end %>
|
|
29
|
+
</tbody>
|
|
30
|
+
</table>
|
|
31
|
+
</div>
|
|
32
|
+
</section>
|
|
33
|
+
<% end %>
|
|
34
|
+
|