llm_cost_tracker 0.2.0.alpha1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -2
  3. data/README.md +4 -3
  4. data/app/assets/llm_cost_tracker/application.css +760 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +13 -0
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  10. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +42 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  14. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  15. data/app/services/llm_cost_tracker/dashboard/filter.rb +0 -3
  16. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  20. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +106 -74
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +201 -111
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +178 -78
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  30. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  31. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  32. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  33. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  34. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  35. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  37. data/config/routes.rb +3 -0
  38. data/lib/llm_cost_tracker/assets.rb +24 -0
  39. data/lib/llm_cost_tracker/engine.rb +2 -0
  40. data/lib/llm_cost_tracker/llm_api_call.rb +1 -1
  41. data/lib/llm_cost_tracker/price_registry.rb +17 -6
  42. data/lib/llm_cost_tracker/pricing.rb +19 -6
  43. data/lib/llm_cost_tracker/retention.rb +34 -0
  44. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  45. data/lib/llm_cost_tracker/tags_column.rb +13 -1
  46. data/lib/llm_cost_tracker/version.rb +1 -1
  47. data/lib/llm_cost_tracker.rb +1 -0
  48. data/lib/tasks/llm_cost_tracker.rake +8 -0
  49. data/llm_cost_tracker.gemspec +1 -2
  50. metadata +17 -5
  51. data/PLAN_0.2.md +0 -488
@@ -1,4 +1,4 @@
1
- <section class="lct-panel">
2
- <h2>Invalid filter</h2>
3
- <p class="lct-muted"><%= @error_message %></p>
1
+ <section class="lct-panel lct-empty">
2
+ <h2 class="lct-state-title">Invalid filter</h2>
3
+ <p class="lct-state-copy"><%= @error_message %></p>
4
4
  </section>
@@ -1,5 +1,5 @@
1
- <section class="lct-panel">
2
- <h2>Call not found</h2>
3
- <p class="lct-muted">The requested LLM API call could not be found.</p>
1
+ <section class="lct-panel lct-empty">
2
+ <h2 class="lct-state-title">Call not found</h2>
3
+ <p class="lct-state-copy">The requested LLM API call could not be found.</p>
4
4
  <p><%= link_to "Back to calls", calls_path, class: "lct-button lct-button-secondary" %></p>
5
5
  </section>
@@ -1,91 +1,99 @@
1
- <section class="lct-panel">
2
- <h2 class="lct-section-title">Models</h2>
1
+ <section class="lct-panel lct-toolbar">
2
+ <div class="lct-toolbar-head">
3
+ <h2 class="lct-section-title">Models</h2>
4
+ </div>
5
+
3
6
  <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>
7
+ <div class="lct-filter-row lct-filter-row-with-sort">
8
+ <div class="lct-field">
9
+ <label for="lct-models-from">From</label>
10
+ <input id="lct-models-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
11
+ </div>
8
12
 
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
+ <div class="lct-field">
14
+ <label for="lct-models-to">To</label>
15
+ <input id="lct-models-to" type="date" name="to" value="<%= params[:to] %>">
16
+ </div>
13
17
 
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
+ <div class="lct-field">
19
+ <label for="lct-models-provider">Provider</label>
20
+ <%= select_tag :provider,
21
+ options_for_select(provider_filter_options, params[:provider]),
22
+ include_blank: "All providers",
23
+ id: "lct-models-provider" %>
24
+ </div>
18
25
 
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>
26
+ <div class="lct-field">
27
+ <label for="lct-models-model">Model</label>
28
+ <%= select_tag :model,
29
+ options_for_select(model_filter_options, params[:model]),
30
+ include_blank: "All models",
31
+ id: "lct-models-model" %>
32
+ </div>
28
33
 
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>
34
+ <div class="lct-field">
35
+ <label for="lct-models-sort">Sort</label>
36
+ <select id="lct-models-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
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" %>
46
+ <div class="lct-filter-actions">
47
+ <button class="lct-button" type="submit">Apply</button>
48
+ <%= link_to("Reset", models_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
49
+ </div>
49
50
  </div>
50
51
  </form>
52
+
53
+ <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: models_path %>
51
54
  </section>
52
55
 
53
56
  <% if @rows.empty? %>
54
57
  <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>
58
+ <h2 class="lct-state-title">No models in this slice</h2>
59
+ <p class="lct-state-copy">Tracked models will appear here once calls match the current provider, model, and date filters.</p>
60
+ <div class="lct-state-actions">
61
+ <%= link_to "Clear filters", models_path, class: "lct-button lct-button-secondary" %>
62
+ </div>
57
63
  </section>
58
64
  <% else %>
59
65
  <section class="lct-panel">
60
66
  <div class="lct-table-wrap">
61
- <table class="lct-table">
67
+ <table class="lct-table lct-table-compact">
62
68
  <thead>
63
69
  <tr>
64
70
  <th>Provider</th>
65
71
  <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>
72
+ <th class="lct-num">Calls</th>
73
+ <th class="lct-num">Input</th>
74
+ <th class="lct-num">Output</th>
75
+ <th class="lct-num">Total cost</th>
76
+ <th class="lct-num">Avg cost / call</th>
71
77
  <% if @latency_available %>
72
- <th>Avg latency</th>
78
+ <th class="lct-num">Avg latency</th>
73
79
  <% end %>
80
+ <th></th>
74
81
  </tr>
75
82
  </thead>
76
83
  <tbody>
77
84
  <% @rows.each do |row| %>
78
85
  <tr>
79
86
  <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>
87
+ <td><code class="lct-code"><%= row.model %></code></td>
88
+ <td class="lct-num"><%= number(row.calls) %></td>
89
+ <td class="lct-num"><%= format_tokens(row.input_tokens) %></td>
90
+ <td class="lct-num"><%= format_tokens(row.output_tokens) %></td>
91
+ <td class="lct-num"><%= money(row.total_cost) %></td>
92
+ <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
86
93
  <% if @latency_available %>
87
- <td><%= row.average_latency_ms ? "#{number(row.average_latency_ms.round)}ms" : "n/a" %></td>
94
+ <td class="lct-num<%= ' lct-num-muted' if row.average_latency_ms.nil? %>"><%= row.average_latency_ms ? "#{number(row.average_latency_ms.round)}ms" : "n/a" %></td>
88
95
  <% end %>
96
+ <td><%= link_to "Calls", calls_path(calls_query_for_model(provider: row.provider, model: row.model)), class: "lct-button lct-button-secondary lct-button-compact" %></td>
89
97
  </tr>
90
98
  <% end %>
91
99
  </tbody>
@@ -0,0 +1,16 @@
1
+ <% if chips.any? %>
2
+ <div class="lct-chip-row" aria-label="Active filters">
3
+ <% chips.each do |chip| %>
4
+ <span class="lct-chip">
5
+ <span class="lct-chip-label"><%= chip[:label] %></span>
6
+ <span><%= chip[:value] %></span>
7
+ <% if chip[:path] %>
8
+ <%= link_to "×", chip[:path], class: "lct-chip-remove", aria: { label: "Remove #{chip[:label]} #{chip[:value]}" } %>
9
+ <% end %>
10
+ </span>
11
+ <% end %>
12
+ <% if local_assigns[:clear_path] %>
13
+ <%= link_to "Clear all", clear_path, class: "lct-clear-link" %>
14
+ <% end %>
15
+ </div>
16
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <% visible_segments = stack_segments(segments) %>
2
+
3
+ <% if visible_segments.empty? %>
4
+ <p class="lct-call-breakdown-empty"><%= empty_message %></p>
5
+ <% else %>
6
+ <div class="lct-stack-track" aria-hidden="true">
7
+ <% visible_segments.each do |segment| %>
8
+ <span class="lct-stack-fill <%= segment[:css_class] %>" style="width: <%= segment[:percent].round(2) %>%"></span>
9
+ <% end %>
10
+ </div>
11
+
12
+ <div class="lct-stack-legend">
13
+ <% visible_segments.each do |segment| %>
14
+ <div class="lct-stack-legend-item">
15
+ <span class="lct-stack-key">
16
+ <span class="lct-stack-swatch <%= segment[:css_class] %>"></span>
17
+ <span><%= segment[:label] %></span>
18
+ </span>
19
+ <span class="lct-stack-meta"><%= segment[:formatted_value] %> · <%= percent(segment[:percent]) %></span>
20
+ </div>
21
+ <% end %>
22
+ </div>
23
+ <% end %>
@@ -0,0 +1,18 @@
1
+ <%# locals: series: Array[{ label:, cost: }], comparison_series: nil %>
2
+ <% if series.blank? %>
3
+ <div class="lct-chart-empty">No spend in this range.</div>
4
+ <% else %>
5
+ <%= spend_chart_svg(series, comparison_points: local_assigns[:comparison_series]) %>
6
+ <div class="lct-chart-legend">
7
+ <span><%= series.first[:label] %></span>
8
+ <% if local_assigns[:comparison_series].present? %>
9
+ <span class="lct-chart-legend-compare">
10
+ <span class="lct-chart-key"><span class="lct-chart-key-line"></span> Current</span>
11
+ <span class="lct-chart-key"><span class="lct-chart-key-line lct-chart-key-line-secondary"></span> Previous</span>
12
+ </span>
13
+ <% else %>
14
+ <span>Peak <%= money(series.map { |p| p[:cost] }.max) %></span>
15
+ <% end %>
16
+ <span><%= series.last[:label] %></span>
17
+ </div>
18
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <%# locals: tags: Hash, limit: Integer (optional) %>
2
+ <% entries = tag_chip_entries(tags, limit: local_assigns.fetch(:limit, 3)) %>
3
+ <% if entries.empty? %>
4
+ <span class="lct-tag-empty">(untagged)</span>
5
+ <% else %>
6
+ <span class="lct-tag-chips" title="<%= safe_json(tags) %>">
7
+ <% entries.each do |entry| %>
8
+ <% if entry[:more] %>
9
+ <span class="lct-tag-chip lct-tag-chip-more">+<%= entry[:more] %></span>
10
+ <% else %>
11
+ <span class="lct-tag-chip"><%= entry[:key] %>=<%= entry[:value] %></span>
12
+ <% end %>
13
+ <% end %>
14
+ </span>
15
+ <% end %>
@@ -1,5 +1,6 @@
1
- <section class="lct-panel">
2
- <p class="lct-muted">
1
+ <section class="lct-panel lct-empty">
2
+ <h2 class="lct-state-title">Setup required</h2>
3
+ <p class="lct-state-copy">
3
4
  The <span class="lct-code">llm_api_calls</span> table is not available yet.
4
5
  Run <span class="lct-code">rails generate llm_cost_tracker:install</span> and migrate your database.
5
6
  </p>
@@ -1,19 +1,63 @@
1
+ <section class="lct-panel lct-toolbar">
2
+ <div class="lct-toolbar-head">
3
+ <h2 class="lct-section-title">Tag keys</h2>
4
+ </div>
5
+
6
+ <form class="lct-filters" action="<%= tags_path %>" method="get">
7
+ <div class="lct-filter-row lct-filter-row-basic">
8
+ <div class="lct-field">
9
+ <label for="lct-tags-from">From</label>
10
+ <input id="lct-tags-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
11
+ </div>
12
+
13
+ <div class="lct-field">
14
+ <label for="lct-tags-to">To</label>
15
+ <input id="lct-tags-to" type="date" name="to" value="<%= params[:to] %>">
16
+ </div>
17
+
18
+ <div class="lct-field">
19
+ <label for="lct-tags-provider">Provider</label>
20
+ <%= select_tag :provider,
21
+ options_for_select(provider_filter_options, params[:provider]),
22
+ include_blank: "All providers",
23
+ id: "lct-tags-provider" %>
24
+ </div>
25
+
26
+ <div class="lct-field">
27
+ <label for="lct-tags-model">Model</label>
28
+ <%= select_tag :model,
29
+ options_for_select(model_filter_options, params[:model]),
30
+ include_blank: "All models",
31
+ id: "lct-tags-model" %>
32
+ </div>
33
+
34
+ <div class="lct-filter-actions">
35
+ <button class="lct-button" type="submit">Apply</button>
36
+ <%= link_to("Reset", tags_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
37
+ </div>
38
+ </div>
39
+ </form>
40
+
41
+ <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tags_path %>
42
+ </section>
43
+
1
44
  <% if @rows.empty? %>
2
45
  <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>
46
+ <h2 class="lct-state-title">No tag keys found</h2>
47
+ <p class="lct-state-copy">Tag keys will appear here once tagged calls exist in the current slice.</p>
48
+ <div class="lct-state-actions">
49
+ <%= link_to "Clear filters", tags_path, class: "lct-button lct-button-secondary" %>
50
+ </div>
5
51
  </section>
6
52
  <% else %>
7
53
  <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
54
  <div class="lct-table-wrap">
11
- <table class="lct-table">
55
+ <table class="lct-table lct-table-compact">
12
56
  <thead>
13
57
  <tr>
14
- <th>Tag Key</th>
15
- <th>Calls with this key</th>
16
- <th>Distinct values</th>
58
+ <th>Tag key</th>
59
+ <th class="lct-num">Calls with this key</th>
60
+ <th class="lct-num">Distinct values</th>
17
61
  <th></th>
18
62
  </tr>
19
63
  </thead>
@@ -21,9 +65,9 @@
21
65
  <% @rows.each do |row| %>
22
66
  <tr>
23
67
  <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>
68
+ <td class="lct-num"><%= number(row.calls_count) %></td>
69
+ <td class="lct-num"><%= number(row.distinct_values) %></td>
70
+ <td><%= link_to "Breakdown", tag_path(row.key, current_query), class: "lct-button lct-button-secondary lct-button-compact" %></td>
27
71
  </tr>
28
72
  <% end %>
29
73
  </tbody>
@@ -31,4 +75,3 @@
31
75
  </div>
32
76
  </section>
33
77
  <% end %>
34
-
@@ -1,65 +1,114 @@
1
- <section class="lct-panel">
2
- <p class="lct-muted"><%= link_to "← All tag keys", tags_path %></p>
3
- <h2 class="lct-section-title">Tag key: <code class="lct-code"><%= @tag_key %></code></h2>
4
- <% if @total_calls.positive? %>
5
- <p class="lct-muted">
6
- <%= number(@tagged_calls) %> of <%= number(@total_calls) %> calls have this key
7
- (<%= percent(coverage_percent(@tagged_calls, @total_calls)) %> coverage) &middot;
8
- <%= number(@distinct_values) %> distinct <%= @distinct_values == 1 ? "value" : "values" %>
9
- </p>
10
- <% end %>
11
- <form class="lct-filters" action="<%= tag_path(@tag_key) %>" method="get">
12
- <div class="lct-field">
13
- <label for="lct-from">From</label>
14
- <input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
15
- </div>
1
+ <% share_base = @tagged_calls.positive? ? @tagged_calls.to_f : 1.0 %>
16
2
 
17
- <div class="lct-field">
18
- <label for="lct-to">To</label>
19
- <input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
3
+ <section class="lct-panel lct-toolbar">
4
+ <div class="lct-toolbar-head">
5
+ <div>
6
+ <p class="lct-muted"><%= link_to "← All tag keys", tags_path(current_query) %></p>
7
+ <h2 class="lct-section-title">Tag: <code class="lct-code"><%= @tag_key %></code></h2>
20
8
  </div>
9
+ </div>
21
10
 
22
- <div class="lct-field">
23
- <label for="lct-provider">Provider</label>
24
- <input id="lct-provider" type="text" name="provider" value="<%= params[:provider] %>">
25
- </div>
11
+ <form class="lct-filters" action="<%= tag_path(@tag_key) %>" method="get">
12
+ <div class="lct-filter-row lct-filter-row-basic">
13
+ <div class="lct-field">
14
+ <label for="lct-tag-show-from">From</label>
15
+ <input id="lct-tag-show-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
16
+ </div>
26
17
 
27
- <div class="lct-field">
28
- <label for="lct-model">Model</label>
29
- <input id="lct-model" type="text" name="model" value="<%= params[:model] %>">
30
- </div>
18
+ <div class="lct-field">
19
+ <label for="lct-tag-show-to">To</label>
20
+ <input id="lct-tag-show-to" type="date" name="to" value="<%= params[:to] %>">
21
+ </div>
22
+
23
+ <div class="lct-field">
24
+ <label for="lct-tag-show-provider">Provider</label>
25
+ <%= select_tag :provider,
26
+ options_for_select(provider_filter_options, params[:provider]),
27
+ include_blank: "All providers",
28
+ id: "lct-tag-show-provider" %>
29
+ </div>
30
+
31
+ <div class="lct-field">
32
+ <label for="lct-tag-show-model">Model</label>
33
+ <%= select_tag :model,
34
+ options_for_select(model_filter_options, params[:model]),
35
+ include_blank: "All models",
36
+ id: "lct-tag-show-model" %>
37
+ </div>
31
38
 
32
- <div class="lct-button-row">
33
- <button class="lct-button" type="submit">Apply</button>
34
- <%= link_to "Reset", tag_path(@tag_key), class: "lct-button lct-button-secondary" %>
39
+ <div class="lct-filter-actions">
40
+ <button class="lct-button" type="submit">Apply</button>
41
+ <%= link_to("Reset", tag_path(@tag_key), class: "lct-button lct-button-secondary") if any_filter_applied? %>
42
+ </div>
35
43
  </div>
36
44
  </form>
45
+
46
+ <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tag_path(@tag_key) %>
47
+
48
+ <p class="lct-summary-row">
49
+ <span><strong><%= number(@tagged_calls) %></strong> tagged calls</span>
50
+ <span><strong><%= percent(coverage_percent(@tagged_calls, @total_calls)) %></strong> coverage</span>
51
+ <span><strong><%= number(@distinct_values) %></strong> distinct values</span>
52
+ </p>
37
53
  </section>
38
54
 
39
55
  <% if @rows.empty? %>
40
56
  <section class="lct-panel lct-empty">
41
- <h2 class="lct-section-title">No calls tagged with <%= @tag_key %></h2>
42
- <p class="lct-muted">Calls carrying this tag will appear here when they match the current filters.</p>
57
+ <h2 class="lct-state-title">No calls tagged with <%= @tag_key %></h2>
58
+ <p class="lct-state-copy">Values for this key will appear here once matching calls carry the tag in the current slice.</p>
59
+ <div class="lct-state-actions">
60
+ <%= link_to "Clear filters", tag_path(@tag_key), class: "lct-button lct-button-secondary" %>
61
+ </div>
43
62
  </section>
44
63
  <% else %>
64
+ <section class="lct-stat-grid lct-stat-grid-spaced">
65
+ <article class="lct-stat">
66
+ <p class="lct-stat-label">Tagged calls</p>
67
+ <p class="lct-stat-value"><%= number(@tagged_calls) %></p>
68
+ <p class="lct-stat-copy">Rows that include <code class="lct-code"><%= @tag_key %></code></p>
69
+ </article>
70
+
71
+ <article class="lct-stat">
72
+ <p class="lct-stat-label">Coverage</p>
73
+ <p class="lct-stat-value"><%= percent(coverage_percent(@tagged_calls, @total_calls)) %></p>
74
+ <p class="lct-stat-copy"><%= number(@total_calls) %> total calls in this slice</p>
75
+ </article>
76
+
77
+ <article class="lct-stat">
78
+ <p class="lct-stat-label">Distinct values</p>
79
+ <p class="lct-stat-value"><%= number(@distinct_values) %></p>
80
+ <p class="lct-stat-copy">Unique values currently visible</p>
81
+ </article>
82
+ </section>
83
+
45
84
  <section class="lct-panel">
46
85
  <div class="lct-table-wrap">
47
- <table class="lct-table">
86
+ <table class="lct-table lct-table-compact">
48
87
  <thead>
49
88
  <tr>
50
89
  <th>Value</th>
51
- <th>Calls</th>
52
- <th>Total cost</th>
53
- <th>Avg cost / call</th>
90
+ <th class="lct-num">Calls</th>
91
+ <th class="lct-num">Share</th>
92
+ <th class="lct-num">Total cost</th>
93
+ <th class="lct-num">Avg cost / call</th>
94
+ <th></th>
54
95
  </tr>
55
96
  </thead>
56
97
  <tbody>
57
98
  <% @rows.each do |row| %>
58
99
  <tr>
59
- <td><%= row.value %></td>
60
- <td><%= number(row.calls) %></td>
61
- <td><%= money(row.total_cost) %></td>
62
- <td><%= money(row.average_cost_per_call) %></td>
100
+ <td><code class="lct-code"><%= row.value %></code></td>
101
+ <td class="lct-num"><%= number(row.calls) %></td>
102
+ <td class="lct-num"><%= percent((row.calls / share_base) * 100.0) %></td>
103
+ <td class="lct-num"><%= money(row.total_cost) %></td>
104
+ <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
105
+ <td>
106
+ <% if row.value == "(untagged)" %>
107
+ <span class="lct-muted">n/a</span>
108
+ <% else %>
109
+ <%= link_to "Calls", calls_path(calls_query_for_tag(key: @tag_key, value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
110
+ <% end %>
111
+ </td>
63
112
  </tr>
64
113
  <% end %>
65
114
  </tbody>
data/config/routes.rb CHANGED
@@ -7,4 +7,7 @@ LlmCostTracker::Engine.routes.draw do
7
7
  get "tags", to: "tags#index", as: :tags
8
8
  get "tags/:key", to: "tags#show", as: :tag, format: false
9
9
  get "data_quality", to: "data_quality#index", as: :data_quality
10
+
11
+ get "assets/#{LlmCostTracker::Assets.stylesheet_filename}",
12
+ to: "assets#stylesheet", as: :stylesheet
10
13
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module LlmCostTracker
6
+ module Assets
7
+ ROOT = File.expand_path("../../app/assets/llm_cost_tracker", __dir__)
8
+ STYLESHEET = "application.css"
9
+
10
+ class << self
11
+ def root
12
+ ROOT
13
+ end
14
+
15
+ def stylesheet_fingerprint
16
+ @stylesheet_fingerprint ||= Digest::SHA256.file(File.join(ROOT, STYLESHEET)).hexdigest[0, 12]
17
+ end
18
+
19
+ def stylesheet_filename
20
+ "application-#{stylesheet_fingerprint}.css"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -3,6 +3,8 @@
3
3
  require "rails"
4
4
  require_relative "../llm_cost_tracker"
5
5
  require_relative "engine_compatibility"
6
+ require_relative "assets"
7
+ require "rack/files"
6
8
 
7
9
  LlmCostTracker::EngineCompatibility.check_rails_version!(Rails.version)
8
10
 
@@ -100,7 +100,7 @@ module LlmCostTracker
100
100
 
101
101
  case connection.adapter_name
102
102
  when /postgres/i
103
- json_column = tags_json_column? ? column : "(#{column})::jsonb"
103
+ json_column = tags_jsonb_column? ? column : "(#{column})::jsonb"
104
104
  "#{json_column}->>#{connection.quote(key)}"
105
105
  when /mysql/i
106
106
  "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "monitor"
4
5
  require "yaml"
5
6
 
6
7
  require_relative "logging"
@@ -11,14 +12,17 @@ module LlmCostTracker
11
12
  EMPTY_PRICES = {}.freeze
12
13
  PRICE_KEYS = %w[input cached_input output cache_read_input cache_creation_input].freeze
13
14
  METADATA_KEYS = %w[_source _updated _notes].freeze
15
+ MUTEX = Monitor.new
14
16
 
15
17
  class << self
16
18
  def builtin_prices
17
- @builtin_prices ||= normalize_price_table(raw_registry.fetch("models", {})).freeze
19
+ @builtin_prices ||= MUTEX.synchronize do
20
+ @builtin_prices || normalize_price_table(raw_registry.fetch("models", {})).freeze
21
+ end
18
22
  end
19
23
 
20
24
  def metadata
21
- @metadata ||= raw_registry.fetch("metadata", {}).freeze
25
+ @metadata ||= MUTEX.synchronize { @metadata || raw_registry.fetch("metadata", {}).freeze }
22
26
  end
23
27
 
24
28
  def normalize_price_table(table)
@@ -35,9 +39,14 @@ module LlmCostTracker
35
39
  cached = @file_prices_cache
36
40
  return cached[:value] if cached && cached[:key] == cache_key
37
41
 
38
- value = normalize_file_prices(price_file_models(load_price_file(path)), path: path).freeze
39
- @file_prices_cache = { key: cache_key, value: value }.freeze
40
- value
42
+ MUTEX.synchronize do
43
+ cached = @file_prices_cache
44
+ return cached[:value] if cached && cached[:key] == cache_key
45
+
46
+ value = normalize_file_prices(price_file_models(load_price_file(path)), path: path).freeze
47
+ @file_prices_cache = { key: cache_key, value: value }.freeze
48
+ value
49
+ end
41
50
  rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError, NoMethodError => e
42
51
  raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
43
52
  end
@@ -45,7 +54,9 @@ module LlmCostTracker
45
54
  private
46
55
 
47
56
  def raw_registry
48
- @raw_registry ||= JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
57
+ @raw_registry ||= MUTEX.synchronize do
58
+ @raw_registry || JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
59
+ end
49
60
  end
50
61
 
51
62
  def normalize_price_entry(price)