llm_cost_tracker 0.1.4 → 0.2.0.alpha2

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -91
  3. data/PLAN_0.2.md +488 -0
  4. data/README.md +140 -320
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
  14. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
  15. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
  16. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
  17. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
  18. data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
  20. data/app/services/llm_cost_tracker/pagination.rb +59 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
  29. data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
  31. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
  32. data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
  34. data/config/routes.rb +10 -0
  35. data/lib/llm_cost_tracker/budget.rb +16 -38
  36. data/lib/llm_cost_tracker/configuration.rb +3 -1
  37. data/lib/llm_cost_tracker/cost.rb +1 -3
  38. data/lib/llm_cost_tracker/engine.rb +13 -0
  39. data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
  40. data/lib/llm_cost_tracker/errors.rb +2 -0
  41. data/lib/llm_cost_tracker/event.rb +1 -3
  42. data/lib/llm_cost_tracker/event_metadata.rb +9 -18
  43. data/lib/llm_cost_tracker/llm_api_call.rb +4 -17
  44. data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
  45. data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
  46. data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
  47. data/lib/llm_cost_tracker/parsers/base.rb +3 -8
  48. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  50. data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
  51. data/lib/llm_cost_tracker/period_grouping.rb +68 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +22 -30
  53. data/lib/llm_cost_tracker/pricing.rb +10 -19
  54. data/lib/llm_cost_tracker/report.rb +4 -4
  55. data/lib/llm_cost_tracker/report_data.rb +21 -24
  56. data/lib/llm_cost_tracker/report_formatter.rb +4 -2
  57. data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
  58. data/lib/llm_cost_tracker/tag_key.rb +16 -0
  59. data/lib/llm_cost_tracker/tracker.rb +35 -1
  60. data/lib/llm_cost_tracker/version.rb +1 -1
  61. data/lib/llm_cost_tracker.rb +3 -6
  62. data/llm_cost_tracker.gemspec +13 -9
  63. metadata +91 -20
  64. data/.rubocop.yml +0 -44
  65. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
  66. data/lib/llm_cost_tracker/storage/backends.rb +0 -26
  67. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
  68. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
  69. data/lib/llm_cost_tracker/value_object.rb +0 -45
@@ -0,0 +1,69 @@
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>
16
+
17
+ <div class="lct-field">
18
+ <label for="lct-to">To</label>
19
+ <input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
20
+ </div>
21
+
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>
26
+
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>
31
+
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" %>
35
+ </div>
36
+ </form>
37
+ </section>
38
+
39
+ <% if @rows.empty? %>
40
+ <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>
43
+ </section>
44
+ <% else %>
45
+ <section class="lct-panel">
46
+ <div class="lct-table-wrap">
47
+ <table class="lct-table">
48
+ <thead>
49
+ <tr>
50
+ <th>Value</th>
51
+ <th>Calls</th>
52
+ <th>Total cost</th>
53
+ <th>Avg cost / call</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ <% @rows.each do |row| %>
58
+ <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>
63
+ </tr>
64
+ <% end %>
65
+ </tbody>
66
+ </table>
67
+ </div>
68
+ </section>
69
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ LlmCostTracker::Engine.routes.draw do
4
+ root "dashboard#index"
5
+ resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
6
+ resources :models, only: :index
7
+ get "tags", to: "tags#index", as: :tags
8
+ get "tags/:key", to: "tags#show", as: :tag, format: false
9
+ get "data_quality", to: "data_quality#index", as: :data_quality
10
+ end
@@ -4,17 +4,15 @@ require_relative "logging"
4
4
 
5
5
  module LlmCostTracker
6
6
  class Budget
7
- WARNING_MUTEX = Mutex.new
8
- private_constant :WARNING_MUTEX
9
-
10
7
  class << self
11
8
  def enforce!
12
- return unless LlmCostTracker.configuration.monthly_budget
13
- return unless behavior == :block_requests
14
- return warn_non_active_record_block_requests unless LlmCostTracker.configuration.active_record?
9
+ config = LlmCostTracker.configuration
10
+ return unless config.monthly_budget
11
+ return unless config.budget_exceeded_behavior == :block_requests
12
+ return unless config.active_record?
15
13
 
16
- monthly_total = calculate_monthly_total(0)
17
- return unless monthly_total >= LlmCostTracker.configuration.monthly_budget
14
+ monthly_total = active_record_monthly_total
15
+ return unless monthly_total >= config.monthly_budget
18
16
 
19
17
  handle_exceeded(monthly_total: monthly_total)
20
18
  end
@@ -22,24 +20,20 @@ module LlmCostTracker
22
20
  def check!(event)
23
21
  config = LlmCostTracker.configuration
24
22
  return unless config.monthly_budget
25
- return unless event[:cost]
23
+ return unless event.cost
26
24
 
27
- monthly_total = calculate_monthly_total(event[:cost][:total_cost])
28
- return unless monthly_total > config.monthly_budget
25
+ monthly_total = if config.active_record?
26
+ active_record_monthly_total
27
+ else
28
+ event.cost.total_cost
29
+ end
30
+ return unless monthly_total >= config.monthly_budget
29
31
 
30
32
  handle_exceeded(monthly_total: monthly_total, last_event: event)
31
33
  end
32
34
 
33
35
  private
34
36
 
35
- def calculate_monthly_total(latest_cost)
36
- if LlmCostTracker.configuration.active_record?
37
- active_record_monthly_total
38
- else
39
- latest_cost
40
- end
41
- end
42
-
43
37
  def active_record_monthly_total
44
38
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
45
39
  require_relative "storage/active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
@@ -49,18 +43,6 @@ module LlmCostTracker
49
43
  raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
50
44
  end
51
45
 
52
- def warn_non_active_record_block_requests
53
- should_warn = WARNING_MUTEX.synchronize do
54
- unless @warned_non_active_record_block_requests
55
- @warned_non_active_record_block_requests = true
56
- true
57
- end
58
- end
59
- return unless should_warn
60
-
61
- Logging.warn(":block_requests preflight requires storage_backend = :active_record; request was not blocked.")
62
- end
63
-
64
46
  def handle_exceeded(monthly_total:, last_event: nil)
65
47
  config = LlmCostTracker.configuration
66
48
  payload = {
@@ -70,15 +52,11 @@ module LlmCostTracker
70
52
  }
71
53
 
72
54
  config.on_budget_exceeded&.call(payload)
73
- raise BudgetExceededError.new(**payload) if raise_on_exceeded?
74
- end
75
-
76
- def raise_on_exceeded?
77
- %i[raise block_requests].include?(behavior)
55
+ raise BudgetExceededError.new(**payload) if raise_on_exceeded?(config)
78
56
  end
79
57
 
80
- def behavior
81
- LlmCostTracker.configuration.budget_exceeded_behavior
58
+ def raise_on_exceeded?(config)
59
+ %i[raise block_requests].include?(config.budget_exceeded_behavior)
82
60
  end
83
61
  end
84
62
  end
@@ -22,7 +22,8 @@ module LlmCostTracker
22
22
  :monthly_budget, # Float, in USD — nil means no limit
23
23
  :log_level, # :debug, :info, :warn
24
24
  :prices_file, # JSON/YAML file that overrides built-in prices
25
- :pricing_overrides # Hash to override built-in pricing
25
+ :pricing_overrides, # Hash to override built-in pricing
26
+ :report_tag_breakdowns # Array of tag keys to break down in the rake report
26
27
 
27
28
  attr_reader :budget_exceeded_behavior, # :notify, :raise, :block_requests
28
29
  :storage_backend, # :log, :active_record, :custom
@@ -43,6 +44,7 @@ module LlmCostTracker
43
44
  @log_level = :info
44
45
  @prices_file = nil
45
46
  @pricing_overrides = {}
47
+ @report_tag_breakdowns = []
46
48
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
47
49
  end
48
50
 
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "value_object"
4
-
5
3
  module LlmCostTracker
6
- Cost = ValueObject.define(
4
+ Cost = Data.define(
7
5
  :input_cost,
8
6
  :cached_input_cost,
9
7
  :cache_read_input_cost,
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require_relative "../llm_cost_tracker"
5
+ require_relative "engine_compatibility"
6
+
7
+ LlmCostTracker::EngineCompatibility.check_rails_version!(Rails.version)
8
+
9
+ module LlmCostTracker
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace LlmCostTracker
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module EngineCompatibility
5
+ REQUIRED_RAILS_VERSION = Gem::Version.new("7.1.0")
6
+
7
+ class << self
8
+ def check_rails_version!(version)
9
+ return if Gem::Version.new(version) >= REQUIRED_RAILS_VERSION
10
+
11
+ raise LlmCostTracker::Error, "LlmCostTracker::Engine requires Rails 7.1+"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -3,6 +3,8 @@
3
3
  module LlmCostTracker
4
4
  class Error < StandardError; end
5
5
 
6
+ class InvalidFilterError < Error; end
7
+
6
8
  class BudgetExceededError < Error
7
9
  attr_reader :monthly_total, :budget, :last_event
8
10
 
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "value_object"
4
-
5
3
  module LlmCostTracker
6
- Event = ValueObject.define(
4
+ Event = Data.define(
7
5
  :provider,
8
6
  :model,
9
7
  :input_tokens,
@@ -16,22 +16,17 @@ module LlmCostTracker
16
16
 
17
17
  class << self
18
18
  def usage_data(input_tokens, output_tokens, metadata)
19
- cache_read_input_tokens = integer_metadata(metadata, :cache_read_input_tokens, :cache_read_tokens)
20
- cache_creation_input_tokens = integer_metadata(
21
- metadata,
22
- :cache_creation_input_tokens,
23
- :cache_creation_tokens
24
- )
25
- cached_input_tokens = integer_metadata(metadata, :cached_input_tokens)
19
+ metadata = metadata.to_h.symbolize_keys
20
+ cache_read = first_integer(metadata, :cache_read_input_tokens, :cache_read_tokens)
21
+ cache_creation = first_integer(metadata, :cache_creation_input_tokens, :cache_creation_tokens)
26
22
 
27
23
  {
28
24
  input_tokens: input_tokens.to_i,
29
25
  output_tokens: output_tokens.to_i,
30
- cached_input_tokens: cached_input_tokens,
31
- cache_read_input_tokens: cache_read_input_tokens,
32
- cache_creation_input_tokens: cache_creation_input_tokens,
33
- total_tokens: input_tokens.to_i + output_tokens.to_i +
34
- cache_read_input_tokens + cache_creation_input_tokens
26
+ cached_input_tokens: metadata[:cached_input_tokens].to_i,
27
+ cache_read_input_tokens: cache_read,
28
+ cache_creation_input_tokens: cache_creation,
29
+ total_tokens: input_tokens.to_i + output_tokens.to_i + cache_read + cache_creation
35
30
  }
36
31
  end
37
32
 
@@ -41,12 +36,8 @@ module LlmCostTracker
41
36
 
42
37
  private
43
38
 
44
- def integer_metadata(metadata, *keys)
45
- keys.each do |key|
46
- value = metadata[key] || metadata[key.to_s]
47
- return value.to_i unless value.nil?
48
- end
49
-
39
+ def first_integer(metadata, *keys)
40
+ keys.each { |key| return metadata[key].to_i unless metadata[key].nil? }
50
41
  0
51
42
  end
52
43
  end
@@ -2,24 +2,21 @@
2
2
 
3
3
  require "active_record"
4
4
 
5
+ require_relative "period_grouping"
5
6
  require_relative "tag_accessors"
7
+ require_relative "tag_key"
6
8
  require_relative "tag_query"
7
9
  require_relative "tags_column"
8
10
 
9
11
  module LlmCostTracker
10
12
  class LlmApiCall < ActiveRecord::Base
11
- TAG_KEY_PATTERN = /\A[\w.-]+\z/
12
-
13
- private_constant :TAG_KEY_PATTERN
14
-
13
+ extend PeriodGrouping
15
14
  extend TagsColumn
16
15
  include TagAccessors
17
16
 
18
17
  self.table_name = "llm_api_calls"
19
18
 
20
19
  # Scopes for querying
21
- scope :by_provider, ->(provider) { where(provider: provider) }
22
- scope :by_model, ->(model) { where(model: model) }
23
20
  scope :with_cost, -> { where.not(total_cost: nil) }
24
21
  scope :without_cost, -> { where(total_cost: nil) }
25
22
  scope :unknown_pricing, -> { without_cost }
@@ -92,13 +89,6 @@ module LlmCostTracker
92
89
  group(:provider).average(:latency_ms).transform_values(&:to_f)
93
90
  end
94
91
 
95
- def self.daily_costs(days: 30)
96
- where(tracked_at: days.days.ago..)
97
- .group("DATE(tracked_at)")
98
- .sum(:total_cost)
99
- .transform_keys(&:to_s)
100
- end
101
-
102
92
  def self.tag_label(value)
103
93
  value.nil? || value == "" ? "(untagged)" : value.to_s
104
94
  end
@@ -121,10 +111,7 @@ module LlmCostTracker
121
111
  private_class_method :tag_group_expression
122
112
 
123
113
  def self.validated_tag_key(key)
124
- key = key.to_s
125
- return key if key.match?(TAG_KEY_PATTERN)
126
-
127
- raise ArgumentError, "invalid tag key: #{key.inspect}"
114
+ TagKey.validate!(key)
128
115
  end
129
116
  private_class_method :validated_tag_key
130
117
 
@@ -37,10 +37,10 @@ module LlmCostTracker
37
37
  return unless parsed
38
38
 
39
39
  Tracker.record(
40
- provider: parsed[:provider],
41
- model: parsed[:model],
42
- input_tokens: parsed[:input_tokens],
43
- output_tokens: parsed[:output_tokens],
40
+ provider: parsed.provider,
41
+ model: parsed.model,
42
+ input_tokens: parsed.input_tokens,
43
+ output_tokens: parsed.output_tokens,
44
44
  latency_ms: latency_ms,
45
45
  metadata: resolved_tags(request_env).merge(parsed.metadata)
46
46
  )
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "value_object"
4
-
5
3
  module LlmCostTracker
6
- ParsedUsage = ValueObject.define(
4
+ ParsedUsage = Data.define(
7
5
  :provider,
8
6
  :model,
9
7
  :input_tokens,
@@ -15,10 +13,10 @@ module LlmCostTracker
15
13
  :reasoning_tokens
16
14
  )
17
15
 
18
- ParsedUsage.const_set(:TRACKING_KEYS, %i[provider model input_tokens output_tokens total_tokens].freeze)
16
+ class ParsedUsage
17
+ TRACKING_KEYS = %i[provider model input_tokens output_tokens total_tokens].freeze
19
18
 
20
- class << ParsedUsage
21
- def build(**attributes)
19
+ def self.build(**attributes)
22
20
  new(
23
21
  provider: attributes.fetch(:provider),
24
22
  model: attributes.fetch(:model),
@@ -31,11 +29,9 @@ module LlmCostTracker
31
29
  reasoning_tokens: attributes[:reasoning_tokens]
32
30
  )
33
31
  end
34
- end
35
32
 
36
- class ParsedUsage
37
33
  def metadata
38
- except(*TRACKING_KEYS)
34
+ to_h.except(*TRACKING_KEYS)
39
35
  end
40
36
 
41
37
  def to_h
@@ -28,11 +28,10 @@ module LlmCostTracker
28
28
  ParsedUsage.build(
29
29
  provider: "anthropic",
30
30
  model: response["model"] || request["model"],
31
- input_tokens: usage["input_tokens"] || 0,
32
- output_tokens: usage["output_tokens"] || 0,
33
- total_tokens: (usage["input_tokens"] || 0) + (usage["output_tokens"] || 0) +
34
- (usage["cache_read_input_tokens"] || 0) +
35
- (usage["cache_creation_input_tokens"] || 0),
31
+ input_tokens: usage["input_tokens"].to_i,
32
+ output_tokens: usage["output_tokens"].to_i,
33
+ total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i +
34
+ usage["cache_read_input_tokens"].to_i + usage["cache_creation_input_tokens"].to_i,
36
35
  cache_read_input_tokens: usage["cache_read_input_tokens"],
37
36
  cache_creation_input_tokens: usage["cache_creation_input_tokens"]
38
37
  )
@@ -5,15 +5,10 @@ require "json"
5
5
  module LlmCostTracker
6
6
  module Parsers
7
7
  class Base
8
- # Returns a hash with parsed usage data, or nil if not applicable.
8
+ # Parse a provider response into a {LlmCostTracker::ParsedUsage}, or return
9
+ # nil when the response is not trackable (non-200, missing usage, etc).
9
10
  #
10
- # Expected return format:
11
- # {
12
- # provider: "openai",
13
- # model: "gpt-4o",
14
- # input_tokens: 150,
15
- # output_tokens: 42
16
- # }
11
+ # @return [LlmCostTracker::ParsedUsage, nil]
17
12
  def parse(request_url, request_body, response_status, response_body)
18
13
  raise NotImplementedError
19
14
  end
@@ -30,9 +30,9 @@ module LlmCostTracker
30
30
  ParsedUsage.build(
31
31
  provider: "gemini",
32
32
  model: model,
33
- input_tokens: usage["promptTokenCount"] || 0,
33
+ input_tokens: usage["promptTokenCount"].to_i,
34
34
  output_tokens: output_tokens(usage),
35
- total_tokens: usage["totalTokenCount"] || 0,
35
+ total_tokens: usage["totalTokenCount"].to_i,
36
36
  cached_input_tokens: usage["cachedContentTokenCount"]
37
37
  )
38
38
  end
@@ -40,7 +40,7 @@ module LlmCostTracker
40
40
  private
41
41
 
42
42
  def output_tokens(usage)
43
- (usage["candidatesTokenCount"] || 0) + (usage["thoughtsTokenCount"] || 0)
43
+ usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
44
44
  end
45
45
 
46
46
  def extract_model_from_url(url)
@@ -17,9 +17,9 @@ module LlmCostTracker
17
17
  ParsedUsage.build(
18
18
  provider: provider_for(request_url),
19
19
  model: response["model"] || request["model"],
20
- input_tokens: usage["prompt_tokens"] || usage["input_tokens"] || 0,
21
- output_tokens: usage["completion_tokens"] || usage["output_tokens"] || 0,
22
- total_tokens: usage["total_tokens"] || 0,
20
+ input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
21
+ output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
22
+ total_tokens: usage["total_tokens"].to_i,
23
23
  cached_input_tokens: cached_input_tokens(usage)
24
24
  )
25
25
  end
@@ -4,33 +4,26 @@ module LlmCostTracker
4
4
  module Parsers
5
5
  class Registry
6
6
  class << self
7
- PARSERS_MUTEX = Mutex.new
8
-
9
7
  def parsers
10
- @parsers || PARSERS_MUTEX.synchronize { @parsers ||= default_parsers }
8
+ @parsers ||= default_parsers
11
9
  end
12
10
 
13
11
  def register(parser)
14
- PARSERS_MUTEX.synchronize { parsers.unshift(parser) }
12
+ parsers.unshift(parser)
15
13
  end
16
14
 
17
15
  def find_for(url)
18
- parsers.find { |p| p.match?(url) }
16
+ parsers.find { |parser| parser.match?(url) }
19
17
  end
20
18
 
21
19
  def reset!
22
- PARSERS_MUTEX.synchronize { @parsers = nil }
20
+ @parsers = nil
23
21
  end
24
22
 
25
23
  private
26
24
 
27
25
  def default_parsers
28
- [
29
- Openai.new,
30
- OpenaiCompatible.new,
31
- Anthropic.new,
32
- Gemini.new
33
- ]
26
+ [Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
34
27
  end
35
28
  end
36
29
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module PeriodGrouping
5
+ PERIOD_FORMATS = {
6
+ day: {
7
+ postgres: "YYYY-MM-DD",
8
+ mysql: "%Y-%m-%d",
9
+ sqlite: "%Y-%m-%d"
10
+ },
11
+ month: {
12
+ postgres: "YYYY-MM",
13
+ mysql: "%Y-%m",
14
+ sqlite: "%Y-%m"
15
+ }
16
+ }.freeze
17
+
18
+ private_constant :PERIOD_FORMATS
19
+
20
+ def group_by_period(period, column: :tracked_at)
21
+ group(Arel.sql(period_group_expression(period, column: column)))
22
+ end
23
+
24
+ def daily_costs(days: 30)
25
+ where(tracked_at: days.days.ago..)
26
+ .group_by_period(:day)
27
+ .sum(:total_cost)
28
+ end
29
+
30
+ private
31
+
32
+ def period_group_expression(period, column:)
33
+ period = validated_period(period)
34
+ column = period_column_expression(column)
35
+ formats = PERIOD_FORMATS.fetch(period)
36
+
37
+ case connection.adapter_name
38
+ when /postgres/i
39
+ postgres_period_expression(period, column, formats)
40
+ when /mysql/i
41
+ "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
42
+ else
43
+ "strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"
44
+ end
45
+ end
46
+
47
+ def postgres_period_expression(period, column, formats)
48
+ "TO_CHAR(" \
49
+ "DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
50
+ "#{connection.quote(formats.fetch(:postgres))}" \
51
+ ")"
52
+ end
53
+
54
+ def validated_period(period)
55
+ normalized_period = period.respond_to?(:to_sym) ? period.to_sym : nil
56
+ return normalized_period if PERIOD_FORMATS.key?(normalized_period)
57
+
58
+ raise ArgumentError, "invalid period: #{period.inspect}"
59
+ end
60
+
61
+ def period_column_expression(column)
62
+ column = column.to_s
63
+ return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
64
+
65
+ raise ArgumentError, "invalid period column: #{column.inspect}"
66
+ end
67
+ end
68
+ end