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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. 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
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. 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 total_cost DESC, calls DESC, value ASC
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(Arel.sql(order_sql))
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
- <main class="lct-shell">
14
- <header class="lct-header">
15
- <div class="lct-header-copy">
16
- <h1 class="lct-title">LLM Cost Tracker</h1>
17
- </div>
18
- <nav class="lct-nav" aria-label="Dashboard">
19
- <%= link_to "Overview", root_path, class: ("lct-active" if request.path.delete_suffix("/") == root_path.delete_suffix("/")) %>
20
- <%= link_to "Models", models_path, class: ("lct-active" if request.path.start_with?(models_path)) %>
21
- <%= link_to "Calls", calls_path, class: ("lct-active" if request.path.start_with?(calls_path)) %>
22
- <%= link_to "Tags", tags_path, class: ("lct-active" if request.path.start_with?(tags_path)) %>
23
- <%= link_to "Data Quality", data_quality_path, class: ("lct-active" if request.path.start_with?(data_quality_path)) %>
24
- <% if LlmCostTracker.reconciliation_enabled? %>
25
- <%= link_to "Reconciliation", reconciliation_path, class: ("lct-active" if request.path.start_with?(reconciliation_path)) %>
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
- </nav>
28
- </header>
87
+ <% end %>
88
+ </div>
89
+ </aside>
29
90
 
30
- <%= body %>
31
- </main>
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
- <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",
6
- calls_path(current_query(format: :csv)),
7
- class: "lct-button lct-button-secondary",
8
- title: "Capped at #{number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT)} rows per request — narrow the date range to export larger slices." %>
9
- </div>
10
- </div>
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
- <%= render "llm_cost_tracker/shared/filters", path: calls_path %>
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
- <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: calls_path %>
15
- </section>
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
- <div class="lct-results-toolbar">
28
- <span class="lct-pagination-per">
29
- <span class="lct-pagination-per-label">Per page:</span>
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
- <span class="lct-pagination-per-option is-active" aria-current="true"><%= choice %></span>
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)), class: "lct-pagination-per-option" %>
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 lct-page-arrow", rel: "prev", aria: { label: "Previous page" } %>
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-page-arrow is-disabled" aria-disabled="true" aria-label="Previous page">‹</span>
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-gap" aria-hidden="true">…</span>
85
+ <span class="lct-page-link lct-disabled" aria-hidden="true">…</span>
106
86
  <% elsif item == @page.page %>
107
- <span class="lct-page-link is-current" aria-current="page"><%= item %></span>
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 lct-page-arrow", rel: "next", aria: { label: "Next page" } %>
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-page-arrow is-disabled" aria-disabled="true" aria-label="Next page">›</span>
95
+ <span class="lct-page-link lct-disabled" aria-disabled="true" aria-label="Next page">›</span>
117
96
  <% end %>
118
- </div>
119
- </nav>
97
+ </span>
98
+ </div>
120
99
  </section>
121
100
  <% end %>