llm_cost_tracker 0.2.0.alpha2 → 0.3.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +114 -70
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +760 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  15. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  16. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
  18. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  19. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  20. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  21. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  22. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  23. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  24. data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  26. data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
  27. data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
  28. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  30. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  31. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  32. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  39. data/config/routes.rb +3 -0
  40. data/lib/llm_cost_tracker/assets.rb +19 -0
  41. data/lib/llm_cost_tracker/configuration.rb +78 -42
  42. data/lib/llm_cost_tracker/engine.rb +2 -0
  43. data/lib/llm_cost_tracker/event.rb +2 -0
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  48. data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
  49. data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
  50. data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
  51. data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
  52. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  53. data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
  54. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  55. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
  56. data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
  57. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  58. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  59. data/lib/llm_cost_tracker/price_registry.rb +18 -7
  60. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  61. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  62. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  63. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  64. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  65. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  66. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  67. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  68. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  69. data/lib/llm_cost_tracker/price_sync.rb +310 -0
  70. data/lib/llm_cost_tracker/pricing.rb +19 -6
  71. data/lib/llm_cost_tracker/retention.rb +34 -0
  72. data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
  73. data/lib/llm_cost_tracker/stream_collector.rb +158 -0
  74. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  75. data/lib/llm_cost_tracker/tags_column.rb +21 -1
  76. data/lib/llm_cost_tracker/tracker.rb +15 -12
  77. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  78. data/lib/llm_cost_tracker/version.rb +1 -1
  79. data/lib/llm_cost_tracker.rb +51 -29
  80. data/lib/tasks/llm_cost_tracker.rake +124 -0
  81. data/llm_cost_tracker.gemspec +9 -8
  82. metadata +40 -12
  83. data/PLAN_0.2.md +0 -488
@@ -1,84 +1,110 @@
1
- <section class="lct-panel">
2
- <h2 class="lct-section-title">Calls</h2>
3
- <form class="lct-filters" action="<%= calls_path %>" method="get">
4
- <div class="lct-field">
5
- <label for="lct-from">From</label>
6
- <input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
7
- </div>
8
-
9
- <div class="lct-field">
10
- <label for="lct-to">To</label>
11
- <input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
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", calls_path(current_query(format: :csv)), class: "lct-button lct-button-secondary" %>
12
6
  </div>
7
+ </div>
13
8
 
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>
9
+ <form class="lct-filters" action="<%= calls_path %>" method="get">
10
+ <div class="lct-filter-row lct-filter-row-with-sort">
11
+ <div class="lct-field">
12
+ <label for="lct-from">From</label>
13
+ <input id="lct-from" data-lct-filter-input type="date" name="from" value="<%= params[:from] %>">
14
+ </div>
18
15
 
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>
16
+ <div class="lct-field">
17
+ <label for="lct-to">To</label>
18
+ <input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
19
+ </div>
23
20
 
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>
21
+ <div class="lct-field">
22
+ <label for="lct-provider">Provider</label>
23
+ <%= select_tag :provider,
24
+ options_for_select(provider_filter_options, params[:provider]),
25
+ include_blank: "All providers",
26
+ id: "lct-provider" %>
27
+ </div>
28
28
 
29
- <div class="lct-field">
30
- <label for="lct-tag-value">Tag value</label>
31
- <input id="lct-tag-value" type="text" name="tag_value" value="<%= params[:tag_value] %>">
32
- </div>
29
+ <div class="lct-field">
30
+ <label for="lct-model">Model</label>
31
+ <%= select_tag :model,
32
+ options_for_select(model_filter_options, params[:model]),
33
+ include_blank: "All models",
34
+ id: "lct-model" %>
35
+ </div>
33
36
 
34
- <div class="lct-field">
35
- <label for="lct-sort">Sort / filter</label>
36
- <select id="lct-sort" name="sort">
37
- <option value="" <%= "selected" if @sort.blank? %>>Recent first</option>
38
- <option value="expensive" <%= "selected" if @sort == "expensive" %>>Most expensive</option>
39
- <option value="input" <%= "selected" if @sort == "input" %>>Largest input</option>
40
- <option value="output" <%= "selected" if @sort == "output" %>>Largest output</option>
41
- <% if @latency_available %>
42
- <option value="slow" <%= "selected" if @sort == "slow" %>>Slowest</option>
43
- <% end %>
44
- <option value="unknown_pricing" <%= "selected" if @sort == "unknown_pricing" %>>Unknown pricing only</option>
45
- </select>
46
- </div>
37
+ <% if LlmCostTracker::LlmApiCall.stream_column? %>
38
+ <div class="lct-field">
39
+ <label for="lct-stream">Stream</label>
40
+ <%= select_tag :stream,
41
+ options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
42
+ include_blank: "All calls",
43
+ id: "lct-stream" %>
44
+ </div>
45
+ <% end %>
47
46
 
48
- <div class="lct-field">
49
- <label for="lct-per">Per page</label>
50
- <input id="lct-per" type="number" name="per" min="1" max="200" value="<%= @page.per %>">
51
- </div>
47
+ <div class="lct-field">
48
+ <label for="lct-sort">Sort</label>
49
+ <select id="lct-sort" name="sort">
50
+ <option value="" <%= "selected" if @sort.blank? %>>Recent first</option>
51
+ <option value="expensive" <%= "selected" if @sort == "expensive" %>>Most expensive</option>
52
+ <option value="input" <%= "selected" if @sort == "input" %>>Largest input</option>
53
+ <option value="output" <%= "selected" if @sort == "output" %>>Largest output</option>
54
+ <% if @latency_available %>
55
+ <option value="slow" <%= "selected" if @sort == "slow" %>>Slowest</option>
56
+ <% end %>
57
+ <option value="unknown_pricing" <%= "selected" if @sort == "unknown_pricing" %>>Unknown pricing only</option>
58
+ </select>
59
+ </div>
52
60
 
53
- <div class="lct-button-row">
54
- <button class="lct-button" type="submit">Apply</button>
55
- <%= link_to "Reset", calls_path, class: "lct-button lct-button-secondary" %>
56
- <%= link_to "Export CSV", calls_path(current_query(format: :csv)), class: "lct-button lct-button-secondary" %>
61
+ <div class="lct-filter-actions">
62
+ <button class="lct-button" type="submit">Apply</button>
63
+ <%= link_to("Reset", calls_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
64
+ </div>
57
65
  </div>
58
66
  </form>
59
- <p class="lct-muted">CSV export is capped at <%= number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT) %> rows per request — narrow the date range to export larger slices.</p>
67
+
68
+ <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: calls_path %>
69
+
70
+ <p class="lct-summary-row">
71
+ <span class="lct-pagination-per">
72
+ <span class="lct-pagination-per-label">Per page:</span>
73
+ <% LlmCostTracker::PaginationHelper::PER_PAGE_CHOICES.each do |choice| %>
74
+ <% if choice == @page.per %>
75
+ <span class="lct-pagination-per-option is-active" aria-current="true"><%= choice %></span>
76
+ <% else %>
77
+ <%= link_to choice, calls_path(current_query(per: choice, page: 1)), class: "lct-pagination-per-option" %>
78
+ <% end %>
79
+ <% end %>
80
+ </span>
81
+ </p>
82
+ <p class="lct-toolbar-note">CSV export is capped at <%= number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT) %> rows per request — narrow the date range to export larger slices.</p>
60
83
  </section>
61
84
 
62
85
  <% if @calls_count.zero? %>
63
86
  <section class="lct-panel lct-empty">
64
- <h2 class="lct-section-title">No matching calls</h2>
65
- <p class="lct-muted">Tracked requests will appear here when they match the current filters.</p>
87
+ <h2 class="lct-state-title">No matching calls</h2>
88
+ <p class="lct-state-copy">Tracked requests will appear here when they match the current filters.</p>
89
+ <div class="lct-state-actions">
90
+ <%= link_to "Clear filters", calls_path, class: "lct-button lct-button-secondary" %>
91
+ </div>
66
92
  </section>
67
93
  <% else %>
68
94
  <section class="lct-panel">
69
95
  <div class="lct-table-wrap">
70
- <table class="lct-table lct-calls-table">
96
+ <table class="lct-table lct-table-compact lct-calls-table">
71
97
  <thead>
72
98
  <tr>
73
99
  <th>Tracked At</th>
74
100
  <th>Provider</th>
75
101
  <th>Model</th>
76
- <th>Input</th>
77
- <th>Output</th>
78
- <th>Total</th>
79
- <th>Cost</th>
102
+ <th class="lct-num">Input</th>
103
+ <th class="lct-num">Output</th>
104
+ <th class="lct-num">Total</th>
105
+ <th class="lct-num">Cost</th>
80
106
  <% if @latency_available %>
81
- <th>Latency</th>
107
+ <th class="lct-num">Latency</th>
82
108
  <% end %>
83
109
  <th>Tags</th>
84
110
  <th></th>
@@ -90,18 +116,16 @@
90
116
  <tr>
91
117
  <td class="lct-nowrap"><%= format_date(call.tracked_at) %></td>
92
118
  <td><%= call.provider %></td>
93
- <td><%= call.model %></td>
94
- <td><%= format_tokens(call.input_tokens) %></td>
95
- <td><%= format_tokens(call.output_tokens) %></td>
96
- <td><%= format_tokens(call.total_tokens) %></td>
97
- <td><%= optional_money(call.total_cost) %></td>
119
+ <td><code class="lct-code"><%= call.model %></code></td>
120
+ <td class="lct-num"><%= format_tokens(call.input_tokens) %></td>
121
+ <td class="lct-num"><%= format_tokens(call.output_tokens) %></td>
122
+ <td class="lct-num"><%= format_tokens(call.total_tokens) %></td>
123
+ <td class="lct-num<%= ' lct-num-muted' if call.total_cost.nil? %>"><%= optional_money(call.total_cost) %></td>
98
124
  <% if @latency_available %>
99
- <td><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
125
+ <td class="lct-num<%= ' lct-num-muted' if call.latency_ms.nil? %>"><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
100
126
  <% end %>
101
- <td>
102
- <span class="lct-tags" title="<%= safe_json(tags) %>"><%= tags_summary(tags) %></span>
103
- </td>
104
- <td><%= link_to "Details", call_path(call), class: "lct-button lct-button-secondary" %></td>
127
+ <td><%= render "llm_cost_tracker/shared/tag_chips", tags: tags %></td>
128
+ <td><%= link_to "Details", call_path(call), class: "lct-button lct-button-secondary lct-button-compact" %></td>
105
129
  </tr>
106
130
  <% end %>
107
131
  </tbody>
@@ -111,17 +135,35 @@
111
135
  <nav class="lct-pagination" aria-label="Pagination">
112
136
  <% first_row = @page.offset + 1 %>
113
137
  <% last_row = [@page.offset + @calls.length, @calls_count].min %>
114
- <span class="lct-muted">
115
- <%= number(first_row) %>-<%= number(last_row) %> of <%= number(@calls_count) %>
116
- </span>
117
- <span>
138
+ <% total_pages = @page.total_pages(@calls_count) %>
139
+
140
+ <div class="lct-pagination-info">
141
+ <span>Showing <strong><%= number(first_row) %></strong> to <strong><%= number(last_row) %></strong> of <strong><%= number(@calls_count) %></strong> results</span>
142
+ </div>
143
+
144
+ <div class="lct-pagination-nav" role="group" aria-label="Page navigation">
118
145
  <% if @page.prev_page? %>
119
- <%= link_to "Previous", calls_path(current_query(page: @page.page - 1, per: @page.per)), class: "lct-button lct-button-secondary" %>
146
+ <%= 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" } %>
147
+ <% else %>
148
+ <span class="lct-page-link lct-page-arrow is-disabled" aria-disabled="true" aria-label="Previous page">‹</span>
149
+ <% end %>
150
+
151
+ <% pagination_page_items(@page.page, total_pages).each do |item| %>
152
+ <% if item == :gap %>
153
+ <span class="lct-page-gap" aria-hidden="true">…</span>
154
+ <% elsif item == @page.page %>
155
+ <span class="lct-page-link is-current" aria-current="page"><%= item %></span>
156
+ <% else %>
157
+ <%= link_to item, calls_path(current_query(page: item, per: @page.per)), class: "lct-page-link", aria: { label: "Go to page #{item}" } %>
158
+ <% end %>
120
159
  <% end %>
160
+
121
161
  <% if @page.next_page?(@calls_count) %>
122
- <%= link_to "Next", calls_path(current_query(page: @page.page + 1, per: @page.per)), class: "lct-button lct-button-secondary" %>
162
+ <%= 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" } %>
163
+ <% else %>
164
+ <span class="lct-page-link lct-page-arrow is-disabled" aria-disabled="true" aria-label="Next page">›</span>
123
165
  <% end %>
124
- </span>
166
+ </div>
125
167
  </nav>
126
168
  </section>
127
169
  <% end %>
@@ -1,6 +1,63 @@
1
+ <% token_segments = [
2
+ { label: "Input", value: @call.input_tokens, formatted_value: format_tokens(@call.input_tokens), css_class: "lct-stack-fill-input" },
3
+ { label: "Output", value: @call.output_tokens, formatted_value: format_tokens(@call.output_tokens), css_class: "lct-stack-fill-output" }
4
+ ] %>
5
+ <% cost_segments = [
6
+ { label: "Input", value: @call.input_cost, formatted_value: optional_money(@call.input_cost), css_class: "lct-stack-fill-input" },
7
+ { label: "Output", value: @call.output_cost, formatted_value: optional_money(@call.output_cost), css_class: "lct-stack-fill-output" }
8
+ ] %>
9
+
1
10
  <section class="lct-panel">
2
11
  <p class="lct-muted"><%= link_to "Back to calls", calls_path %></p>
3
- <h2 class="lct-section-title">Call #<%= @call.id %></h2>
12
+ <div class="lct-call-hero">
13
+ <div>
14
+ <h2 class="lct-section-title lct-call-title">Call #<%= @call.id %></h2>
15
+ <p class="lct-call-subtitle">
16
+ <code class="lct-code"><%= @call.provider %></code>
17
+ <span>·</span>
18
+ <code class="lct-code"><%= @call.model %></code>
19
+ <span>·</span>
20
+ <span><%= format_date(@call.tracked_at) %></span>
21
+ </p>
22
+ </div>
23
+
24
+ <div class="lct-call-summary">
25
+ <div class="lct-call-summary-item">
26
+ <span class="lct-call-summary-label">Pricing</span>
27
+ <strong><%= pricing_status(@call) %></strong>
28
+ </div>
29
+ <div class="lct-call-summary-item">
30
+ <span class="lct-call-summary-label">Total cost</span>
31
+ <strong><%= optional_money(@call.total_cost) %></strong>
32
+ </div>
33
+ <div class="lct-call-summary-item">
34
+ <span class="lct-call-summary-label">Total tokens</span>
35
+ <strong><%= format_tokens(@call.total_tokens) %></strong>
36
+ </div>
37
+ <% if @latency_available %>
38
+ <div class="lct-call-summary-item">
39
+ <span class="lct-call-summary-label">Latency</span>
40
+ <strong><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></strong>
41
+ </div>
42
+ <% end %>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="lct-call-breakdown-grid">
47
+ <section class="lct-call-breakdown">
48
+ <h3 class="lct-call-breakdown-title">Token Mix</h3>
49
+ <%= render "llm_cost_tracker/shared/metric_stack", segments: token_segments, empty_message: "No token data for this call." %>
50
+ </section>
51
+
52
+ <section class="lct-call-breakdown">
53
+ <h3 class="lct-call-breakdown-title">Cost Mix</h3>
54
+ <% if @call.total_cost.nil? %>
55
+ <p class="lct-call-breakdown-empty">Pricing not available for this call.</p>
56
+ <% else %>
57
+ <%= render "llm_cost_tracker/shared/metric_stack", segments: cost_segments, empty_message: "No cost breakdown for this call." %>
58
+ <% end %>
59
+ </section>
60
+ </div>
4
61
 
5
62
  <div class="lct-detail-grid">
6
63
  <dl class="lct-dl">