llm_cost_tracker 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -4,21 +4,25 @@ module LlmCostTracker
|
|
|
4
4
|
module Dashboard
|
|
5
5
|
class TagBreakdown
|
|
6
6
|
DEFAULT_LIMIT = 100
|
|
7
|
+
SORT_OPTIONS = %w[value calls cost avg_cost].freeze
|
|
8
|
+
DEFAULT_DIRECTIONS = { "value" => "asc", "calls" => "desc", "cost" => "desc", "avg_cost" => "desc" }.freeze
|
|
7
9
|
Row = Data.define(:value, :calls, :total_cost, :average_cost_per_call, :share_percent)
|
|
8
10
|
|
|
9
11
|
class << self
|
|
10
|
-
def call(key:, scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
|
|
11
|
-
new(scope: scope, key: key, limit: limit)
|
|
12
|
+
def call(key:, scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT, sort: "cost", direction: nil)
|
|
13
|
+
new(scope: scope, key: key, limit: limit, sort: sort, direction: direction)
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
attr_reader :limit
|
|
16
18
|
|
|
17
|
-
def initialize(scope:, key:, limit:)
|
|
19
|
+
def initialize(scope:, key:, limit:, sort: "cost", direction: nil)
|
|
18
20
|
@scope = scope
|
|
19
21
|
@key = LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
|
|
20
22
|
limit = limit.to_i
|
|
21
23
|
@limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
|
24
|
+
@sort = SORT_OPTIONS.include?(sort.to_s) ? sort.to_s : "cost"
|
|
25
|
+
@direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def rows
|
|
@@ -51,7 +55,7 @@ module LlmCostTracker
|
|
|
51
55
|
|
|
52
56
|
private
|
|
53
57
|
|
|
54
|
-
attr_reader :scope, :key
|
|
58
|
+
attr_reader :scope, :key, :sort, :direction
|
|
55
59
|
|
|
56
60
|
def summary_counts
|
|
57
61
|
@summary_counts ||= scope.klass.find_by_sql(summary_sql).first
|
|
@@ -67,11 +71,21 @@ module LlmCostTracker
|
|
|
67
71
|
INNER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
|
|
68
72
|
WHERE #{tag_present_predicate}
|
|
69
73
|
GROUP BY #{tag_value_column}
|
|
70
|
-
ORDER BY
|
|
74
|
+
ORDER BY #{order_clause}
|
|
71
75
|
LIMIT #{limit}
|
|
72
76
|
SQL
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
def order_clause
|
|
80
|
+
dir = direction.upcase
|
|
81
|
+
case sort
|
|
82
|
+
when "value" then "#{tag_value_column} #{dir}"
|
|
83
|
+
when "calls" then "COUNT(*) #{dir}, total_cost DESC"
|
|
84
|
+
when "avg_cost" then "average_cost_per_call #{dir}, total_cost DESC"
|
|
85
|
+
else "total_cost #{dir}, calls DESC, value ASC"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
75
89
|
def summary_sql
|
|
76
90
|
<<~SQL.squish
|
|
77
91
|
SELECT COUNT(*) AS total_calls,
|
|
@@ -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 %>
|