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
|
@@ -4,45 +4,60 @@ module LlmCostTracker
|
|
|
4
4
|
module Dashboard
|
|
5
5
|
class TopModels
|
|
6
6
|
DEFAULT_LIMIT = 5
|
|
7
|
-
SORT_OPTIONS = %w[cost calls avg_cost latency].freeze
|
|
7
|
+
SORT_OPTIONS = %w[cost calls avg_cost latency tokens provider name].freeze
|
|
8
8
|
DEFAULT_SORT = "cost"
|
|
9
|
+
DEFAULT_DIRECTIONS = {
|
|
10
|
+
"provider" => "asc",
|
|
11
|
+
"name" => "asc",
|
|
12
|
+
"calls" => "desc",
|
|
13
|
+
"tokens" => "desc",
|
|
14
|
+
"latency" => "desc",
|
|
15
|
+
"avg_cost" => "desc",
|
|
16
|
+
"cost" => "desc"
|
|
17
|
+
}.freeze
|
|
18
|
+
ORDER_NODES = {
|
|
19
|
+
%w[provider asc] => [{ provider: :asc, model: :asc }],
|
|
20
|
+
%w[provider desc] => [{ provider: :desc, model: :asc }],
|
|
21
|
+
%w[name asc] => [{ model: :asc }],
|
|
22
|
+
%w[name desc] => [{ model: :desc }],
|
|
23
|
+
%w[calls asc] => [Arel.sql("COUNT(*) ASC")],
|
|
24
|
+
%w[calls desc] => [Arel.sql("COUNT(*) DESC")],
|
|
25
|
+
%w[tokens asc] => [Arel.sql("COALESCE(SUM(total_tokens), 0) ASC")],
|
|
26
|
+
%w[tokens desc] => [Arel.sql("COALESCE(SUM(total_tokens), 0) DESC")],
|
|
27
|
+
%w[avg_cost asc] => [Arel.sql("COALESCE(SUM(total_cost), 0) / NULLIF(COUNT(*), 0) ASC")],
|
|
28
|
+
%w[avg_cost desc] => [Arel.sql("COALESCE(SUM(total_cost), 0) / NULLIF(COUNT(*), 0) DESC")],
|
|
29
|
+
%w[latency asc] => [Arel.sql("CASE WHEN AVG(latency_ms) IS NULL THEN 1 ELSE 0 END ASC, " \
|
|
30
|
+
"AVG(latency_ms) ASC")],
|
|
31
|
+
%w[latency desc] => [Arel.sql("CASE WHEN AVG(latency_ms) IS NULL THEN 1 ELSE 0 END ASC, " \
|
|
32
|
+
"AVG(latency_ms) DESC")],
|
|
33
|
+
%w[cost asc] => [Arel.sql("COALESCE(SUM(total_cost), 0) ASC")],
|
|
34
|
+
%w[cost desc] => [Arel.sql("COALESCE(SUM(total_cost), 0) DESC")]
|
|
35
|
+
}.freeze
|
|
9
36
|
|
|
10
37
|
class << self
|
|
11
|
-
def call(scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT, sort: DEFAULT_SORT)
|
|
12
|
-
new(scope: scope, limit: limit, sort: sort).rows
|
|
38
|
+
def call(scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT, sort: DEFAULT_SORT, direction: nil)
|
|
39
|
+
new(scope: scope, limit: limit, sort: sort, direction: direction).rows
|
|
13
40
|
end
|
|
14
41
|
end
|
|
15
42
|
|
|
16
|
-
def initialize(scope:, limit:, sort: DEFAULT_SORT)
|
|
43
|
+
def initialize(scope:, limit:, sort: DEFAULT_SORT, direction: nil)
|
|
17
44
|
@scope = scope
|
|
18
45
|
@limit = limit
|
|
19
46
|
@sort = SORT_OPTIONS.include?(sort.to_s) ? sort.to_s : DEFAULT_SORT
|
|
47
|
+
@direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
|
|
20
48
|
end
|
|
21
49
|
|
|
22
50
|
def rows
|
|
23
51
|
scope
|
|
24
52
|
.group(:provider, :model)
|
|
25
53
|
.select(selects)
|
|
26
|
-
.order(
|
|
54
|
+
.order(*ORDER_NODES.fetch([sort, direction]))
|
|
27
55
|
.then { |r| limit ? r.limit(limit) : r }
|
|
28
56
|
end
|
|
29
57
|
|
|
30
58
|
private
|
|
31
59
|
|
|
32
|
-
attr_reader :scope, :limit, :sort
|
|
33
|
-
|
|
34
|
-
def order_sql
|
|
35
|
-
case sort
|
|
36
|
-
when "calls"
|
|
37
|
-
"COUNT(*) DESC"
|
|
38
|
-
when "avg_cost"
|
|
39
|
-
"COALESCE(SUM(total_cost), 0) / NULLIF(COUNT(*), 0) DESC"
|
|
40
|
-
when "latency"
|
|
41
|
-
"CASE WHEN AVG(latency_ms) IS NULL THEN 1 ELSE 0 END ASC, AVG(latency_ms) DESC"
|
|
42
|
-
else
|
|
43
|
-
"COALESCE(SUM(total_cost), 0) DESC"
|
|
44
|
-
end
|
|
45
|
-
end
|
|
60
|
+
attr_reader :scope, :limit, :sort, :direction
|
|
46
61
|
|
|
47
62
|
def selects
|
|
48
63
|
columns = [
|
|
@@ -7,28 +7,91 @@
|
|
|
7
7
|
<title>LLM Cost Tracker</title>
|
|
8
8
|
<%= stylesheet_link_tag stylesheet_path %>
|
|
9
9
|
<%= inline_style_block %>
|
|
10
|
+
<script nonce="<%= dashboard_csp_nonce %>">
|
|
11
|
+
(function () {
|
|
12
|
+
try {
|
|
13
|
+
var v = localStorage.getItem("lct_theme");
|
|
14
|
+
if (v === "dark" || v === "light") document.documentElement.setAttribute("data-theme", v);
|
|
15
|
+
} catch (e) {}
|
|
16
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
17
|
+
var btn = document.querySelector("[data-lct-theme-toggle]");
|
|
18
|
+
if (btn) {
|
|
19
|
+
btn.addEventListener("click", function () {
|
|
20
|
+
var cur = document.documentElement.getAttribute("data-theme");
|
|
21
|
+
if (!cur) {
|
|
22
|
+
cur = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
23
|
+
}
|
|
24
|
+
var next = cur === "dark" ? "light" : "dark";
|
|
25
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
26
|
+
try { localStorage.setItem("lct_theme", next); } catch (e) {}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
document.addEventListener("keydown", function (e) {
|
|
30
|
+
if (e.key !== "Escape") return;
|
|
31
|
+
var open = document.querySelector("details.lct-filter-pop[open]");
|
|
32
|
+
if (open) { open.removeAttribute("open"); e.preventDefault(); }
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
})();
|
|
36
|
+
</script>
|
|
10
37
|
</head>
|
|
11
38
|
<body class="lct-body">
|
|
12
39
|
<div class="lct-app">
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
40
|
+
<div class="lct-toolbar">
|
|
41
|
+
<%= link_to root_path, class: "lct-toolbar-brand" do %>
|
|
42
|
+
<span class="lct-brandmark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 17l4-5 3 3 6-8 5 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
|
43
|
+
<span>LLM Cost Tracker</span>
|
|
44
|
+
<% end %>
|
|
45
|
+
<div class="lct-toolbar-right">
|
|
46
|
+
<button class="lct-theme-toggle" type="button" data-lct-theme-toggle aria-label="Toggle theme">
|
|
47
|
+
<svg class="lct-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
48
|
+
<svg class="lct-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="lct-shell">
|
|
54
|
+
<aside class="lct-sidebar" aria-label="Dashboard">
|
|
55
|
+
<div class="lct-sidebar-inner">
|
|
56
|
+
<div class="lct-sidebar-section">Insights</div>
|
|
57
|
+
<%= link_to root_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :overview) || "lct-sidebar-link", aria: (dashboard_section == :overview ? { current: "page" } : {}) do %>
|
|
58
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
|
59
|
+
Overview
|
|
60
|
+
<% end %>
|
|
61
|
+
<%= link_to models_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :models) || "lct-sidebar-link", aria: (dashboard_section == :models ? { current: "page" } : {}) do %>
|
|
62
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
|
|
63
|
+
Models
|
|
64
|
+
<% end %>
|
|
65
|
+
<%= link_to calls_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :calls) || "lct-sidebar-link", aria: (dashboard_section == :calls ? { current: "page" } : {}) do %>
|
|
66
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
67
|
+
Calls
|
|
68
|
+
<% end %>
|
|
69
|
+
<%= link_to tags_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :tags) || "lct-sidebar-link", aria: (dashboard_section == :tags ? { current: "page" } : {}) do %>
|
|
70
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
|
71
|
+
Tags
|
|
72
|
+
<% end %>
|
|
73
|
+
<%= link_to data_quality_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :data_quality) || "lct-sidebar-link", aria: (dashboard_section == :data_quality ? { current: "page" } : {}) do %>
|
|
74
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
75
|
+
Data Quality
|
|
76
|
+
<% end %>
|
|
77
|
+
<div class="lct-sidebar-section">Reference</div>
|
|
78
|
+
<%= link_to pricing_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :pricing) || "lct-sidebar-link", aria: (dashboard_section == :pricing ? { current: "page" } : {}) do %>
|
|
79
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
|
80
|
+
Pricing
|
|
81
|
+
<% end %>
|
|
82
|
+
<% if LlmCostTracker.reconciliation_enabled? %>
|
|
83
|
+
<%= link_to reconciliation_path, class: ("lct-sidebar-link lct-active" if dashboard_section == :reconciliation) || "lct-sidebar-link", aria: (dashboard_section == :reconciliation ? { current: "page" } : {}) do %>
|
|
84
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
|
85
|
+
Reconciliation
|
|
26
86
|
<% end %>
|
|
27
|
-
|
|
28
|
-
|
|
87
|
+
<% end %>
|
|
88
|
+
</div>
|
|
89
|
+
</aside>
|
|
29
90
|
|
|
30
|
-
|
|
31
|
-
|
|
91
|
+
<main class="lct-content">
|
|
92
|
+
<%= body %>
|
|
93
|
+
</main>
|
|
94
|
+
</div>
|
|
32
95
|
</div>
|
|
33
96
|
</body>
|
|
34
97
|
</html>
|
|
@@ -1,121 +1,100 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
<% incomplete_only = params[:cost_status].to_s == "incomplete" %>
|
|
2
|
+
|
|
3
|
+
<div class="lct-filter-row">
|
|
4
|
+
<%= render "llm_cost_tracker/shared/filter_pill_date", path: calls_path %>
|
|
5
|
+
<%= render "llm_cost_tracker/shared/filter_pill_provider", path: calls_path %>
|
|
6
|
+
<%= render "llm_cost_tracker/shared/filter_pill_model", path: calls_path %>
|
|
7
|
+
<%= render "llm_cost_tracker/shared/filter_pill_stream", path: calls_path %>
|
|
8
|
+
|
|
9
|
+
<% if incomplete_only %>
|
|
10
|
+
<%= link_to calls_path(current_query(cost_status: nil, page: nil)), class: "lct-filter-pill lct-active", "aria-label": "Remove incomplete-pricing filter" do %>
|
|
11
|
+
<svg width="12" height="12" 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>
|
|
12
|
+
<span class="lct-filter-pill-key">Pricing</span>
|
|
13
|
+
<span class="lct-filter-pill-value">Incomplete ×</span>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% end %>
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
<% if params[:provider].present? || params[:model].present? || params[:stream].present? || incomplete_only %>
|
|
18
|
+
<%= link_to "× Clear filters", calls_path(current_query(provider: nil, model: nil, stream: nil, cost_status: nil, page: nil)), class: "lct-filter-clear" %>
|
|
19
|
+
<% end %>
|
|
13
20
|
|
|
14
|
-
<%=
|
|
15
|
-
|
|
21
|
+
<span class="lct-filter-row-meta"><%= number(@calls_count) %> call<%= "s" unless @calls_count == 1 %> · <%= money(@calls_total_cost) %></span>
|
|
22
|
+
<%= link_to "Export CSV", calls_path(current_query(format: :csv)), class: "lct-page-link", title: "Capped at #{number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT)} rows per request" %>
|
|
23
|
+
</div>
|
|
16
24
|
|
|
17
25
|
<% if @calls_count.zero? %>
|
|
18
26
|
<section class="lct-panel lct-empty">
|
|
19
27
|
<h2 class="lct-state-title">No matching calls</h2>
|
|
20
28
|
<p class="lct-state-copy">Tracked requests will appear here when they match the current filters.</p>
|
|
21
|
-
<div class="lct-state-actions">
|
|
22
|
-
<%= link_to "Clear filters", calls_path, class: "lct-button lct-button-secondary" %>
|
|
23
|
-
</div>
|
|
24
29
|
</section>
|
|
25
30
|
<% else %>
|
|
26
31
|
<section class="lct-panel">
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
<
|
|
32
|
+
<table class="lct-tbl">
|
|
33
|
+
<thead>
|
|
34
|
+
<tr>
|
|
35
|
+
<%= sortable_header("Tracked at", "tracked_at", num: true) %>
|
|
36
|
+
<%= sortable_header("Provider", "provider") %>
|
|
37
|
+
<%= sortable_header("Model", "model") %>
|
|
38
|
+
<%= sortable_header("Input", "input", num: true) %>
|
|
39
|
+
<%= sortable_header("Output", "output", num: true) %>
|
|
40
|
+
<%= sortable_header("Cost", "cost", num: true) %>
|
|
41
|
+
<%= sortable_header("Latency", "latency", num: true) %>
|
|
42
|
+
<th>Tags</th>
|
|
43
|
+
<th class="lct-num"></th>
|
|
44
|
+
</tr>
|
|
45
|
+
</thead>
|
|
46
|
+
<tbody>
|
|
47
|
+
<% @calls.each do |call| %>
|
|
48
|
+
<tr>
|
|
49
|
+
<td><%= format_date(call.tracked_at) %></td>
|
|
50
|
+
<td><span class="lct-model-cell"><span class="lct-provider-dot lct-provider-dot-<%= call.provider %>"></span><%= call.provider %></span></td>
|
|
51
|
+
<td><code class="lct-code-id"><%= call.model %></code></td>
|
|
52
|
+
<td class="lct-num"><%= number(call.input_tokens) %></td>
|
|
53
|
+
<td class="lct-num"><%= number(call.output_tokens) %></td>
|
|
54
|
+
<td class="lct-num<%= ' lct-num-muted' if call.total_cost.nil? %>"><%= optional_money(call.total_cost) %></td>
|
|
55
|
+
<td class="lct-num<%= ' lct-num-muted' if call.latency_ms.nil? %>"><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
|
|
56
|
+
<td><%= render "llm_cost_tracker/shared/tag_chips", tags: call.parsed_tags %></td>
|
|
57
|
+
<td class="lct-num"><%= link_to "Details", call_path(call), class: "lct-page-link" %></td>
|
|
58
|
+
</tr>
|
|
59
|
+
<% end %>
|
|
60
|
+
</tbody>
|
|
61
|
+
</table>
|
|
62
|
+
|
|
63
|
+
<% first_row = @page.offset + 1 %>
|
|
64
|
+
<% last_row = [@page.offset + @calls.length, @calls_count].min %>
|
|
65
|
+
<% total_pages = @page.total_pages(@calls_count) %>
|
|
66
|
+
<div class="lct-pagination">
|
|
67
|
+
<span class="lct-pagination-info">Showing <strong><%= number(first_row) %></strong>–<strong><%= number(last_row) %></strong> of <strong><%= number(@calls_count) %></strong></span>
|
|
68
|
+
<span class="lct-seg lct-pagination-perpage" title="Rows per page">
|
|
30
69
|
<% LlmCostTracker::PaginationHelper::PER_PAGE_CHOICES.each do |choice| %>
|
|
31
70
|
<% if choice == @page.per %>
|
|
32
|
-
<
|
|
71
|
+
<a class="lct-active" aria-current="true"><%= choice %></a>
|
|
33
72
|
<% else %>
|
|
34
|
-
<%= link_to choice, calls_path(current_query(per: choice, page: 1))
|
|
73
|
+
<%= link_to choice, calls_path(current_query(per: choice, page: 1)) %>
|
|
35
74
|
<% end %>
|
|
36
75
|
<% end %>
|
|
37
76
|
</span>
|
|
38
|
-
|
|
39
|
-
<%= render "llm_cost_tracker/shared/sort",
|
|
40
|
-
current: @sort,
|
|
41
|
-
options: [
|
|
42
|
-
["Recent", ""],
|
|
43
|
-
["Most expensive", "expensive"],
|
|
44
|
-
["Largest input", "input"],
|
|
45
|
-
["Largest output", "output"],
|
|
46
|
-
["Slowest", "slow"],
|
|
47
|
-
["Unknown pricing", "unknown_pricing"]
|
|
48
|
-
],
|
|
49
|
-
path_for_sort: ->(value) { calls_path(current_query(sort: value.presence, page: nil)) } %>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
<div class="lct-table-wrap">
|
|
53
|
-
<table class="lct-table lct-table-compact lct-calls-table">
|
|
54
|
-
<thead>
|
|
55
|
-
<tr>
|
|
56
|
-
<th>Tracked At</th>
|
|
57
|
-
<th>Provider</th>
|
|
58
|
-
<th>Model</th>
|
|
59
|
-
<th class="lct-num">Input</th>
|
|
60
|
-
<th class="lct-num">Output</th>
|
|
61
|
-
<th class="lct-num">Total</th>
|
|
62
|
-
<th class="lct-num">Cost</th>
|
|
63
|
-
<th class="lct-num">Latency</th>
|
|
64
|
-
<th>Tags</th>
|
|
65
|
-
<th></th>
|
|
66
|
-
</tr>
|
|
67
|
-
</thead>
|
|
68
|
-
<tbody>
|
|
69
|
-
<% @calls.each do |call| %>
|
|
70
|
-
<tr>
|
|
71
|
-
<td class="lct-nowrap"><%= format_date(call.tracked_at) %></td>
|
|
72
|
-
<td><%= call.provider %></td>
|
|
73
|
-
<td><code class="lct-code"><%= call.model %></code></td>
|
|
74
|
-
<td class="lct-num"><%= number(call.input_tokens) %></td>
|
|
75
|
-
<td class="lct-num"><%= number(call.output_tokens) %></td>
|
|
76
|
-
<td class="lct-num"><%= number(call.total_tokens) %></td>
|
|
77
|
-
<td class="lct-num<%= ' lct-num-muted' if call.total_cost.nil? %>"><%= optional_money(call.total_cost) %></td>
|
|
78
|
-
<td class="lct-num<%= ' lct-num-muted' if call.latency_ms.nil? %>"><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
|
|
79
|
-
<td><%= render "llm_cost_tracker/shared/tag_chips", tags: call.parsed_tags %></td>
|
|
80
|
-
<td><%= link_to "Details", call_path(call), class: "lct-button lct-button-secondary lct-button-compact" %></td>
|
|
81
|
-
</tr>
|
|
82
|
-
<% end %>
|
|
83
|
-
</tbody>
|
|
84
|
-
</table>
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<nav class="lct-pagination" aria-label="Pagination">
|
|
88
|
-
<% first_row = @page.offset + 1 %>
|
|
89
|
-
<% last_row = [@page.offset + @calls.length, @calls_count].min %>
|
|
90
|
-
<% total_pages = @page.total_pages(@calls_count) %>
|
|
91
|
-
|
|
92
|
-
<div class="lct-pagination-info">
|
|
93
|
-
<span>Showing <strong><%= number(first_row) %></strong> to <strong><%= number(last_row) %></strong> of <strong><%= number(@calls_count) %></strong> results</span>
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
|
-
<div class="lct-pagination-nav" role="group" aria-label="Page navigation">
|
|
77
|
+
<span class="lct-pagination-nav">
|
|
97
78
|
<% if @page.prev_page? %>
|
|
98
|
-
<%= link_to "‹", calls_path(current_query(page: @page.page - 1, per: @page.per)), class: "lct-page-link
|
|
79
|
+
<%= link_to "‹", calls_path(current_query(page: @page.page - 1, per: @page.per)), class: "lct-page-link", rel: "prev", aria: { label: "Previous page" } %>
|
|
99
80
|
<% else %>
|
|
100
|
-
<span class="lct-page-link lct-
|
|
81
|
+
<span class="lct-page-link lct-disabled" aria-disabled="true" aria-label="Previous page">‹</span>
|
|
101
82
|
<% end %>
|
|
102
|
-
|
|
103
83
|
<% pagination_page_items(@page.page, total_pages).each do |item| %>
|
|
104
84
|
<% if item == :gap %>
|
|
105
|
-
<span class="lct-page-
|
|
85
|
+
<span class="lct-page-link lct-disabled" aria-hidden="true">…</span>
|
|
106
86
|
<% elsif item == @page.page %>
|
|
107
|
-
<span class="lct-page-link
|
|
87
|
+
<span class="lct-page-link lct-current" aria-current="page"><%= item %></span>
|
|
108
88
|
<% else %>
|
|
109
89
|
<%= link_to item, calls_path(current_query(page: item, per: @page.per)), class: "lct-page-link", aria: { label: "Go to page #{item}" } %>
|
|
110
90
|
<% end %>
|
|
111
91
|
<% end %>
|
|
112
|
-
|
|
113
92
|
<% if @page.next_page?(@calls_count) %>
|
|
114
|
-
<%= link_to "›", calls_path(current_query(page: @page.page + 1, per: @page.per)), class: "lct-page-link
|
|
93
|
+
<%= link_to "›", calls_path(current_query(page: @page.page + 1, per: @page.per)), class: "lct-page-link", rel: "next", aria: { label: "Next page" } %>
|
|
115
94
|
<% else %>
|
|
116
|
-
<span class="lct-page-link lct-
|
|
95
|
+
<span class="lct-page-link lct-disabled" aria-disabled="true" aria-label="Next page">›</span>
|
|
117
96
|
<% end %>
|
|
118
|
-
</
|
|
119
|
-
</
|
|
97
|
+
</span>
|
|
98
|
+
</div>
|
|
120
99
|
</section>
|
|
121
100
|
<% end %>
|