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,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,19 @@
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
+ STYLESHEET_PATH = File.join(ROOT, STYLESHEET).freeze
10
+ STYLESHEET_FINGERPRINT = Digest::SHA256.file(STYLESHEET_PATH).hexdigest[0, 12].freeze
11
+ STYLESHEET_FILENAME = "application-#{STYLESHEET_FINGERPRINT}.css".freeze
12
+
13
+ class << self
14
+ def root = ROOT
15
+ def stylesheet_fingerprint = STYLESHEET_FINGERPRINT
16
+ def stylesheet_filename = STYLESHEET_FILENAME
17
+ end
18
+ end
19
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "value_helpers"
4
5
 
5
6
  module LlmCostTracker
6
7
  class Configuration
@@ -14,22 +15,32 @@ module LlmCostTracker
14
15
  STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
15
16
  STORAGE_BACKENDS = %i[log active_record custom].freeze
16
17
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
18
+ SHARED_SCALAR_ATTRIBUTES = %i[
19
+ enabled
20
+ custom_storage
21
+ on_budget_exceeded
22
+ monthly_budget
23
+ log_level
24
+ prices_file
25
+ ].freeze
26
+ SHARED_ENUM_ATTRIBUTES = {
27
+ storage_backend: [STORAGE_BACKENDS, :log],
28
+ budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
29
+ storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
30
+ unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
31
+ }.freeze
17
32
 
18
- attr_accessor :enabled,
19
- :custom_storage, # callable object for :custom backend
20
- :default_tags, # Hash of default tags added to every event
21
- :on_budget_exceeded, # callable, receives event hash
22
- :monthly_budget, # Float, in USD — nil means no limit
23
- :log_level, # :debug, :info, :warn
24
- :prices_file, # JSON/YAML file that overrides built-in prices
25
- :pricing_overrides, # Hash to override built-in pricing
26
- :report_tag_breakdowns # Array of tag keys to break down in the rake report
27
-
28
- attr_reader :budget_exceeded_behavior, # :notify, :raise, :block_requests
29
- :storage_backend, # :log, :active_record, :custom
30
- :storage_error_behavior, # :ignore, :warn, :raise
31
- :unknown_pricing_behavior, # :ignore, :warn, :raise
32
- :openai_compatible_providers
33
+ attr_reader(
34
+ *SHARED_SCALAR_ATTRIBUTES,
35
+ :budget_exceeded_behavior,
36
+ :default_tags,
37
+ :pricing_overrides,
38
+ :report_tag_breakdowns,
39
+ :storage_backend,
40
+ :storage_error_behavior,
41
+ :unknown_pricing_behavior,
42
+ :openai_compatible_providers
43
+ )
33
44
 
34
45
  def initialize
35
46
  @enabled = true
@@ -46,55 +57,74 @@ module LlmCostTracker
46
57
  @pricing_overrides = {}
47
58
  @report_tag_breakdowns = []
48
59
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
60
+ @finalized = false
61
+ end
62
+
63
+ def default_tags=(value)
64
+ ensure_shared_configuration_mutable!
65
+ @default_tags = value
49
66
  end
50
67
 
51
68
  def openai_compatible_providers=(providers)
69
+ ensure_shared_configuration_mutable!
52
70
  @openai_compatible_providers = normalize_openai_compatible_providers(providers)
53
71
  end
54
72
 
55
- def storage_backend=(value)
56
- @storage_backend = normalize_enum(:storage_backend, value, STORAGE_BACKENDS, default: :log)
73
+ def pricing_overrides=(value)
74
+ ensure_shared_configuration_mutable!
75
+ @pricing_overrides = value
57
76
  end
58
77
 
59
- def budget_exceeded_behavior=(value)
60
- @budget_exceeded_behavior = normalize_enum(
61
- :budget_exceeded_behavior,
62
- value,
63
- BUDGET_EXCEEDED_BEHAVIORS,
64
- default: :notify
65
- )
78
+ def report_tag_breakdowns=(value)
79
+ ensure_shared_configuration_mutable!
80
+ @report_tag_breakdowns = value
66
81
  end
67
82
 
68
- def storage_error_behavior=(value)
69
- @storage_error_behavior = normalize_enum(
70
- :storage_error_behavior,
71
- value,
72
- STORAGE_ERROR_BEHAVIORS,
73
- default: :warn
74
- )
83
+ SHARED_SCALAR_ATTRIBUTES.each do |name|
84
+ define_method("#{name}=") do |value|
85
+ ensure_shared_configuration_mutable!
86
+ instance_variable_set(:"@#{name}", value)
87
+ end
75
88
  end
76
89
 
77
- def unknown_pricing_behavior=(value)
78
- @unknown_pricing_behavior = normalize_enum(
79
- :unknown_pricing_behavior,
80
- value,
81
- UNKNOWN_PRICING_BEHAVIORS,
82
- default: :warn
83
- )
90
+ SHARED_ENUM_ATTRIBUTES.each do |name, (allowed, default)|
91
+ define_method("#{name}=") do |value|
92
+ ensure_shared_configuration_mutable!
93
+ instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default: default))
94
+ end
84
95
  end
85
96
 
86
97
  def normalize_openai_compatible_providers!
87
98
  self.openai_compatible_providers = openai_compatible_providers
88
99
  end
89
100
 
90
- def active_record?
91
- storage_backend == :active_record
101
+ def finalize!
102
+ @default_tags = ValueHelpers.deep_freeze(@default_tags || {})
103
+ @pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
104
+ @report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
105
+ @openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
106
+ @finalized = true
107
+ self
92
108
  end
93
109
 
94
- def log?
95
- storage_backend == :log
110
+ def finalized? = @finalized
111
+
112
+ def dup_for_configuration
113
+ copy = dup
114
+ copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
115
+ copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
116
+ copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
117
+ copy.instance_variable_set(
118
+ :@openai_compatible_providers,
119
+ ValueHelpers.deep_dup(@openai_compatible_providers || {})
120
+ )
121
+ copy.instance_variable_set(:@finalized, false)
122
+ copy
96
123
  end
97
124
 
125
+ def active_record? = storage_backend == :active_record
126
+ def log? = storage_backend == :log
127
+
98
128
  private
99
129
 
100
130
  def normalize_enum(name, value, allowed, default:)
@@ -110,5 +140,11 @@ module LlmCostTracker
110
140
  normalized[host.to_s.downcase] = provider.to_s
111
141
  end
112
142
  end
143
+
144
+ def ensure_shared_configuration_mutable!
145
+ return unless finalized?
146
+
147
+ raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
148
+ end
113
149
  end
114
150
  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
 
@@ -10,6 +10,8 @@ module LlmCostTracker
10
10
  :cost,
11
11
  :tags,
12
12
  :latency_ms,
13
+ :stream,
14
+ :usage_source,
13
15
  :tracked_at
14
16
  ) do
15
17
  def to_h
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class AddStreamingGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to add llm_api_calls.stream and llm_api_calls.usage_source"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "add_streaming_to_llm_api_calls.rb.erb",
18
+ "db/migrate/add_streaming_to_llm_api_calls.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ unless column_exists?(:llm_api_calls, :stream)
4
+ add_column :llm_api_calls, :stream, :boolean, null: false, default: false
5
+ add_index :llm_api_calls, :stream
6
+ end
7
+
8
+ unless column_exists?(:llm_api_calls, :usage_source)
9
+ add_column :llm_api_calls, :usage_source, :string
10
+ add_index :llm_api_calls, :usage_source
11
+ end
12
+ end
13
+
14
+ def down
15
+ if column_exists?(:llm_api_calls, :usage_source)
16
+ remove_index :llm_api_calls, :usage_source if index_exists?(:llm_api_calls, :usage_source)
17
+ remove_column :llm_api_calls, :usage_source
18
+ end
19
+
20
+ if column_exists?(:llm_api_calls, :stream)
21
+ remove_index :llm_api_calls, :stream if index_exists?(:llm_api_calls, :stream)
22
+ remove_column :llm_api_calls, :stream
23
+ end
24
+ end
25
+ end
@@ -10,6 +10,8 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
10
10
  t.decimal :output_cost, precision: 20, scale: 8
11
11
  t.decimal :total_cost, precision: 20, scale: 8
12
12
  t.integer :latency_ms
13
+ t.boolean :stream, null: false, default: false
14
+ t.string :usage_source
13
15
  if postgresql?
14
16
  t.jsonb :tags, null: false, default: {}
15
17
  else
@@ -24,6 +26,8 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
24
26
  add_index :llm_api_calls, :model
25
27
  add_index :llm_api_calls, :tracked_at
26
28
  add_index :llm_api_calls, [:provider, :tracked_at]
29
+ add_index :llm_api_calls, :stream
30
+ add_index :llm_api_calls, :usage_source
27
31
  add_index :llm_api_calls, :tags, using: :gin if postgresql?
28
32
  end
29
33
 
@@ -14,8 +14,11 @@
14
14
  #
15
15
  # Optional metadata keys, ignored by cost calculation:
16
16
  # - _source
17
+ # - _source_version
18
+ # - _fetched_at
17
19
  # - _updated
18
20
  # - _notes
21
+ # - _validator_override
19
22
  #
20
23
  # Example: custom fine-tune
21
24
  # models:
@@ -30,7 +33,11 @@
30
33
  # "gpt-4o":
31
34
  # input: 2.00
32
35
  # output: 8.00
33
- # _source: "Enterprise agreement"
36
+ # _source: "manual"
34
37
  # _updated: "2026-04-18"
38
+ #
39
+ # Use _source: "manual" for custom or orphaned entries you never want sync to touch.
40
+ # Use _validator_override: ["skip_relative_change"] if a negotiated price would
41
+ # otherwise trip the >3x sync warning.
35
42
 
36
43
  models:
@@ -21,6 +21,14 @@ module LlmCostTracker
21
21
  scope :without_cost, -> { where(total_cost: nil) }
22
22
  scope :unknown_pricing, -> { without_cost }
23
23
  scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
24
+ scope :streaming, -> { stream_column? ? where(stream: true) : none }
25
+ scope :non_streaming, -> { stream_column? ? where(stream: [false, nil]) : all }
26
+ scope :by_usage_source, ->(source) { usage_source_column? ? where(usage_source: source.to_s) : none }
27
+ scope :streaming_missing_usage, lambda {
28
+ return none unless stream_column? && usage_source_column?
29
+
30
+ where(stream: true).where(usage_source: ["unknown", nil])
31
+ }
24
32
 
25
33
  scope :with_json_tags, lambda {
26
34
  if tags_json_column?
@@ -100,7 +108,7 @@ module LlmCostTracker
100
108
 
101
109
  case connection.adapter_name
102
110
  when /postgres/i
103
- json_column = tags_json_column? ? column : "(#{column})::jsonb"
111
+ json_column = tags_jsonb_column? ? column : "(#{column})::jsonb"
104
112
  "#{json_column}->>#{connection.quote(key)}"
105
113
  when /mysql/i
106
114
  "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"