llm_cost_tracker 0.1.1 → 0.1.3

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +333 -30
  4. data/lib/llm_cost_tracker/budget.rb +85 -0
  5. data/lib/llm_cost_tracker/configuration.rb +82 -3
  6. data/lib/llm_cost_tracker/cost.rb +15 -0
  7. data/lib/llm_cost_tracker/errors.rb +37 -0
  8. data/lib/llm_cost_tracker/event.rb +24 -0
  9. data/lib/llm_cost_tracker/event_metadata.rb +54 -0
  10. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +29 -0
  11. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +20 -0
  12. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +9 -0
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +16 -4
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -1
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +36 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +15 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +41 -0
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +29 -0
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +29 -0
  20. data/lib/llm_cost_tracker/llm_api_call.rb +45 -14
  21. data/lib/llm_cost_tracker/logging.rb +44 -0
  22. data/lib/llm_cost_tracker/middleware/faraday.rb +54 -13
  23. data/lib/llm_cost_tracker/parsed_usage.rb +45 -0
  24. data/lib/llm_cost_tracker/parsers/anthropic.rb +6 -4
  25. data/lib/llm_cost_tracker/parsers/base.rb +2 -0
  26. data/lib/llm_cost_tracker/parsers/gemini.rb +12 -5
  27. data/lib/llm_cost_tracker/parsers/openai.rb +11 -22
  28. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +48 -0
  29. data/lib/llm_cost_tracker/parsers/openai_usage.rb +33 -0
  30. data/lib/llm_cost_tracker/parsers/registry.rb +16 -7
  31. data/lib/llm_cost_tracker/price_registry.rb +99 -0
  32. data/lib/llm_cost_tracker/prices.json +51 -0
  33. data/lib/llm_cost_tracker/pricing.rb +103 -77
  34. data/lib/llm_cost_tracker/railtie.rb +8 -0
  35. data/lib/llm_cost_tracker/report.rb +29 -0
  36. data/lib/llm_cost_tracker/report_data.rb +84 -0
  37. data/lib/llm_cost_tracker/report_formatter.rb +59 -0
  38. data/lib/llm_cost_tracker/storage/active_record_backend.rb +19 -0
  39. data/lib/llm_cost_tracker/storage/active_record_store.rb +21 -12
  40. data/lib/llm_cost_tracker/storage/backends.rb +26 -0
  41. data/lib/llm_cost_tracker/storage/custom_backend.rb +16 -0
  42. data/lib/llm_cost_tracker/storage/log_backend.rb +28 -0
  43. data/lib/llm_cost_tracker/tag_accessors.rb +23 -0
  44. data/lib/llm_cost_tracker/tag_query.rb +38 -0
  45. data/lib/llm_cost_tracker/tags_column.rb +16 -0
  46. data/lib/llm_cost_tracker/tracker.rb +43 -97
  47. data/lib/llm_cost_tracker/unknown_pricing.rb +40 -0
  48. data/lib/llm_cost_tracker/value_object.rb +45 -0
  49. data/lib/llm_cost_tracker/version.rb +1 -1
  50. data/lib/llm_cost_tracker.rb +49 -6
  51. data/lib/tasks/llm_cost_tracker.rake +9 -0
  52. data/llm_cost_tracker.gemspec +4 -3
  53. metadata +39 -6
@@ -1,25 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "errors"
4
+
3
5
  module LlmCostTracker
4
6
  class Configuration
7
+ # Hostname => provider name for OpenAI-compatible APIs.
8
+ OPENAI_COMPATIBLE_PROVIDERS = {
9
+ "openrouter.ai" => "openrouter",
10
+ "api.deepseek.com" => "deepseek"
11
+ }.freeze
12
+
13
+ BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
14
+ STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
15
+ STORAGE_BACKENDS = %i[log active_record custom].freeze
16
+ UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
17
+
5
18
  attr_accessor :enabled,
6
- :storage_backend, # :log, :active_record, :custom
7
19
  :custom_storage, # callable object for :custom backend
8
20
  :default_tags, # Hash of default tags added to every event
9
21
  :on_budget_exceeded, # callable, receives event hash
10
22
  :monthly_budget, # Float, in USD — nil means no limit
11
23
  :log_level, # :debug, :info, :warn
24
+ :prices_file, # JSON/YAML file that overrides built-in prices
12
25
  :pricing_overrides # Hash to override built-in pricing
13
26
 
27
+ attr_reader :budget_exceeded_behavior, # :notify, :raise, :block_requests
28
+ :storage_backend, # :log, :active_record, :custom
29
+ :storage_error_behavior, # :ignore, :warn, :raise
30
+ :unknown_pricing_behavior, # :ignore, :warn, :raise
31
+ :openai_compatible_providers
32
+
14
33
  def initialize
15
- @enabled = true
16
- @storage_backend = :log
34
+ @enabled = true
35
+ self.storage_backend = :log
17
36
  @custom_storage = nil
18
37
  @default_tags = {}
19
38
  @on_budget_exceeded = nil
20
39
  @monthly_budget = nil
40
+ self.budget_exceeded_behavior = :notify
41
+ self.storage_error_behavior = :warn
42
+ self.unknown_pricing_behavior = :warn
21
43
  @log_level = :info
44
+ @prices_file = nil
22
45
  @pricing_overrides = {}
46
+ self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
47
+ end
48
+
49
+ def openai_compatible_providers=(providers)
50
+ @openai_compatible_providers = normalize_openai_compatible_providers(providers)
51
+ end
52
+
53
+ def storage_backend=(value)
54
+ @storage_backend = normalize_enum(:storage_backend, value, STORAGE_BACKENDS, default: :log)
55
+ end
56
+
57
+ def budget_exceeded_behavior=(value)
58
+ @budget_exceeded_behavior = normalize_enum(
59
+ :budget_exceeded_behavior,
60
+ value,
61
+ BUDGET_EXCEEDED_BEHAVIORS,
62
+ default: :notify
63
+ )
64
+ end
65
+
66
+ def storage_error_behavior=(value)
67
+ @storage_error_behavior = normalize_enum(
68
+ :storage_error_behavior,
69
+ value,
70
+ STORAGE_ERROR_BEHAVIORS,
71
+ default: :warn
72
+ )
73
+ end
74
+
75
+ def unknown_pricing_behavior=(value)
76
+ @unknown_pricing_behavior = normalize_enum(
77
+ :unknown_pricing_behavior,
78
+ value,
79
+ UNKNOWN_PRICING_BEHAVIORS,
80
+ default: :warn
81
+ )
82
+ end
83
+
84
+ def normalize_openai_compatible_providers!
85
+ self.openai_compatible_providers = openai_compatible_providers
23
86
  end
24
87
 
25
88
  def active_record?
@@ -29,5 +92,21 @@ module LlmCostTracker
29
92
  def log?
30
93
  storage_backend == :log
31
94
  end
95
+
96
+ private
97
+
98
+ def normalize_enum(name, value, allowed, default:)
99
+ value = default if value.nil?
100
+ value = value.to_sym
101
+ return value if allowed.include?(value)
102
+
103
+ raise Error, "Unknown #{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
104
+ end
105
+
106
+ def normalize_openai_compatible_providers(providers)
107
+ (providers || {}).each_with_object({}) do |(host, provider), normalized|
108
+ normalized[host.to_s.downcase] = provider.to_s
109
+ end
110
+ end
32
111
  end
33
112
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "value_object"
4
+
5
+ module LlmCostTracker
6
+ Cost = ValueObject.define(
7
+ :input_cost,
8
+ :cached_input_cost,
9
+ :cache_read_input_cost,
10
+ :cache_creation_input_cost,
11
+ :output_cost,
12
+ :total_cost,
13
+ :currency
14
+ )
15
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Error < StandardError; end
5
+
6
+ class BudgetExceededError < Error
7
+ attr_reader :monthly_total, :budget, :last_event
8
+
9
+ def initialize(monthly_total:, budget:, last_event: nil)
10
+ @monthly_total = monthly_total
11
+ @budget = budget
12
+ @last_event = last_event
13
+
14
+ super("LLM monthly budget exceeded: $#{format('%.6f', monthly_total)} / $#{format('%.6f', budget)}")
15
+ end
16
+ end
17
+
18
+ class UnknownPricingError < Error
19
+ attr_reader :model
20
+
21
+ def initialize(model:)
22
+ @model = model
23
+
24
+ super("No pricing configured for LLM model: #{model.inspect}")
25
+ end
26
+ end
27
+
28
+ class StorageError < Error
29
+ attr_reader :original_error
30
+
31
+ def initialize(original_error)
32
+ @original_error = original_error
33
+
34
+ super("Failed to store LLM cost event: #{original_error.class}: #{original_error.message}")
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "value_object"
4
+
5
+ module LlmCostTracker
6
+ Event = ValueObject.define(
7
+ :provider,
8
+ :model,
9
+ :input_tokens,
10
+ :output_tokens,
11
+ :total_tokens,
12
+ :cost,
13
+ :tags,
14
+ :latency_ms,
15
+ :tracked_at
16
+ ) do
17
+ def to_h
18
+ super.merge(
19
+ cost: cost&.to_h,
20
+ tags: tags ? tags.to_h : {}
21
+ )
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module EventMetadata
5
+ INTERNAL_TAG_KEYS = %w[
6
+ cache_creation_input_tokens
7
+ cache_creation_tokens
8
+ cache_read_input_tokens
9
+ cache_read_tokens
10
+ cached_input_tokens
11
+ input_tokens
12
+ output_tokens
13
+ reasoning_tokens
14
+ total_tokens
15
+ ].freeze
16
+
17
+ class << self
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)
26
+
27
+ {
28
+ input_tokens: input_tokens.to_i,
29
+ 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
35
+ }
36
+ end
37
+
38
+ def tags(metadata)
39
+ metadata.reject { |key, _value| INTERNAL_TAG_KEYS.include?(key.to_s) }
40
+ end
41
+
42
+ private
43
+
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
+
50
+ 0
51
+ end
52
+ end
53
+ end
54
+ end
@@ -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 AddLatencyMsGenerator < 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.latency_ms"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "add_latency_ms_to_llm_api_calls.rb.erb",
18
+ "db/migrate/add_latency_ms_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,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module LlmCostTracker
6
+ module Generators
7
+ class PricesGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates a local LlmCostTracker price override file"
11
+
12
+ def create_prices_file
13
+ template(
14
+ "llm_cost_tracker_prices.yml.erb",
15
+ "config/llm_cost_tracker_prices.yml"
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ class AddLatencyMsToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ add_column :llm_api_calls, :latency_ms, :integer unless column_exists?(:llm_api_calls, :latency_ms)
4
+ end
5
+
6
+ def down
7
+ remove_column :llm_api_calls, :latency_ms if column_exists?(:llm_api_calls, :latency_ms)
8
+ end
9
+ end
@@ -6,10 +6,15 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
6
6
  t.integer :input_tokens, null: false, default: 0
7
7
  t.integer :output_tokens, null: false, default: 0
8
8
  t.integer :total_tokens, null: false, default: 0
9
- t.decimal :input_cost, precision: 12, scale: 8
10
- t.decimal :output_cost, precision: 12, scale: 8
11
- t.decimal :total_cost, precision: 12, scale: 8
12
- t.text :tags
9
+ t.decimal :input_cost, precision: 20, scale: 8
10
+ t.decimal :output_cost, precision: 20, scale: 8
11
+ t.decimal :total_cost, precision: 20, scale: 8
12
+ t.integer :latency_ms
13
+ if postgresql?
14
+ t.jsonb :tags, null: false, default: {}
15
+ else
16
+ t.text :tags
17
+ end
13
18
  t.datetime :tracked_at, null: false
14
19
 
15
20
  t.timestamps
@@ -19,5 +24,12 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
19
24
  add_index :llm_api_calls, :model
20
25
  add_index :llm_api_calls, :tracked_at
21
26
  add_index :llm_api_calls, [:provider, :tracked_at]
27
+ add_index :llm_api_calls, :tags, using: :gin if postgresql?
28
+ end
29
+
30
+ private
31
+
32
+ def postgresql?
33
+ connection.adapter_name.downcase.include?("postgres")
22
34
  end
23
35
  end
@@ -12,6 +12,13 @@ LlmCostTracker.configure do |config|
12
12
 
13
13
  # Monthly budget in USD. Set to nil to disable budget alerts.
14
14
  # config.monthly_budget = 100.00
15
+ # config.budget_exceeded_behavior = :notify # :notify, :raise, or :block_requests
16
+
17
+ # What to do when storage fails.
18
+ # config.storage_error_behavior = :warn # :ignore, :warn, or :raise
19
+
20
+ # What to do when a model has no built-in price and no pricing_overrides entry.
21
+ # config.unknown_pricing_behavior = :warn # :ignore, :warn, or :raise
15
22
 
16
23
  # Callback when monthly budget is exceeded.
17
24
  # config.on_budget_exceeded = ->(data) {
@@ -20,8 +27,14 @@ LlmCostTracker.configure do |config|
20
27
  # # Or send a Slack notification, email, etc.
21
28
  # }
22
29
 
23
- # Override built-in pricing for specific models (per 1M tokens, USD)
30
+ # Load a local JSON/YAML price table that overrides built-in pricing.
31
+ # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.json")
32
+
33
+ # Override pricing for specific models in Ruby (per 1M tokens, USD).
24
34
  # config.pricing_overrides = {
25
35
  # "my-custom-model" => { input: 1.00, output: 2.00 }
26
36
  # }
37
+
38
+ # OpenAI-compatible APIs. OpenRouter and DeepSeek are included by default.
39
+ # config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
27
40
  end
@@ -0,0 +1,36 @@
1
+ # Local LlmCostTracker price overrides.
2
+ #
3
+ # Add only the models you want to override or add. Built-in prices still come
4
+ # from the gem's prices.json, and Ruby pricing_overrides still take precedence.
5
+ #
6
+ # Units: USD per 1M tokens.
7
+ #
8
+ # Supported price keys:
9
+ # - input
10
+ # - output
11
+ # - cached_input
12
+ # - cache_read_input
13
+ # - cache_creation_input
14
+ #
15
+ # Optional metadata keys, ignored by cost calculation:
16
+ # - _source
17
+ # - _updated
18
+ # - _notes
19
+ #
20
+ # Example: custom fine-tune
21
+ # models:
22
+ # "ft:gpt-4o-mini:my-org":
23
+ # input: 0.30
24
+ # cached_input: 0.15
25
+ # output: 1.20
26
+ # _notes: "Internal fine-tune rate"
27
+ #
28
+ # Example: negotiated provider discount
29
+ # models:
30
+ # "gpt-4o":
31
+ # input: 2.00
32
+ # output: 8.00
33
+ # _source: "Enterprise agreement"
34
+ # _updated: "2026-04-18"
35
+
36
+ models:
@@ -0,0 +1,15 @@
1
+ class UpgradeLlmApiCallCostPrecision < ActiveRecord::Migration<%= migration_version %>
2
+ COST_COLUMNS = %i[input_cost output_cost total_cost].freeze
3
+
4
+ def up
5
+ COST_COLUMNS.each do |column|
6
+ change_column :llm_api_calls, column, :decimal, precision: 20, scale: 8
7
+ end
8
+ end
9
+
10
+ def down
11
+ COST_COLUMNS.each do |column|
12
+ change_column :llm_api_calls, column, :decimal, precision: 12, scale: 8
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,41 @@
1
+ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ unless postgresql?
4
+ say "Skipping llm_api_calls.tags JSONB upgrade: database adapter is #{connection.adapter_name}."
5
+ return
6
+ end
7
+
8
+ return if tags_jsonb?
9
+
10
+ remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
11
+
12
+ change_column(
13
+ :llm_api_calls,
14
+ :tags,
15
+ :jsonb,
16
+ using: "CASE WHEN tags IS NULL OR tags = '' THEN '{}'::jsonb ELSE tags::jsonb END",
17
+ default: {},
18
+ null: false
19
+ )
20
+
21
+ add_index :llm_api_calls, :tags, using: :gin unless index_exists?(:llm_api_calls, :tags)
22
+ end
23
+
24
+ def down
25
+ return unless postgresql?
26
+
27
+ remove_index :llm_api_calls, :tags if index_exists?(:llm_api_calls, :tags)
28
+ change_column :llm_api_calls, :tags, :text, using: "tags::text"
29
+ end
30
+
31
+ private
32
+
33
+ def postgresql?
34
+ connection.adapter_name.downcase.include?("postgres")
35
+ end
36
+
37
+ def tags_jsonb?
38
+ column = connection.columns(:llm_api_calls).find { |candidate| candidate.name == "tags" }
39
+ column&.sql_type.to_s.downcase == "jsonb"
40
+ end
41
+ end
@@ -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 UpgradeCostPrecisionGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to widen llm_api_calls cost decimal precision"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_llm_api_call_cost_precision.rb.erb",
18
+ "db/migrate/upgrade_llm_api_call_cost_precision.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,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 UpgradeTagsToJsonbGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to upgrade llm_api_calls.tags to PostgreSQL JSONB"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_llm_api_call_tags_to_jsonb.rb.erb",
18
+ "db/migrate/upgrade_llm_api_call_tags_to_jsonb.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
@@ -2,15 +2,33 @@
2
2
 
3
3
  require "active_record"
4
4
 
5
+ require_relative "tag_accessors"
6
+ require_relative "tag_query"
7
+ require_relative "tags_column"
8
+
5
9
  module LlmCostTracker
6
10
  class LlmApiCall < ActiveRecord::Base
11
+ extend TagsColumn
12
+ include TagAccessors
13
+
7
14
  self.table_name = "llm_api_calls"
8
15
 
9
16
  # Scopes for querying
10
17
  scope :by_provider, ->(provider) { where(provider: provider) }
11
18
  scope :by_model, ->(model) { where(model: model) }
12
- scope :by_tag, lambda { |key, value|
13
- where("tags LIKE ?", "%\"#{key}\":\"#{value}\"%")
19
+ scope :by_user, ->(user_id) { by_tag("user_id", user_id) }
20
+ scope :by_feature, ->(feature) { by_tag("feature", feature) }
21
+ scope :with_cost, -> { where.not(total_cost: nil) }
22
+ scope :without_cost, -> { where(total_cost: nil) }
23
+ scope :unknown_pricing, -> { without_cost }
24
+ scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
25
+
26
+ scope :with_json_tags, lambda {
27
+ if tags_json_column?
28
+ where.not(tags: {})
29
+ else
30
+ where.not(tags: [nil, "", "{}"])
31
+ end
14
32
  }
15
33
 
16
34
  scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
@@ -18,6 +36,14 @@ module LlmCostTracker
18
36
  scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
19
37
  scope :between, ->(from, to) { where(tracked_at: from..to) }
20
38
 
39
+ def self.by_tag(key, value)
40
+ by_tags(key => value)
41
+ end
42
+
43
+ def self.by_tags(tags)
44
+ TagQuery.apply(self, tags)
45
+ end
46
+
21
47
  # Aggregations
22
48
  def self.total_cost
23
49
  sum(:total_cost).to_f
@@ -35,24 +61,29 @@ module LlmCostTracker
35
61
  group(:provider).sum(:total_cost)
36
62
  end
37
63
 
38
- def self.daily_costs(days: 30)
39
- where(tracked_at: days.days.ago..)
40
- .group("DATE(tracked_at)")
41
- .sum(:total_cost)
64
+ def self.average_latency_ms
65
+ return nil unless latency_column?
66
+
67
+ average(:latency_ms)&.to_f
42
68
  end
43
69
 
44
- def parsed_tags
45
- JSON.parse(tags || "{}")
46
- rescue JSON::ParserError
47
- {}
70
+ def self.latency_by_model
71
+ return {} unless latency_column?
72
+
73
+ group(:model).average(:latency_ms).transform_values(&:to_f)
48
74
  end
49
75
 
50
- def feature
51
- parsed_tags["feature"]
76
+ def self.latency_by_provider
77
+ return {} unless latency_column?
78
+
79
+ group(:provider).average(:latency_ms).transform_values(&:to_f)
52
80
  end
53
81
 
54
- def user_id
55
- parsed_tags["user_id"]
82
+ def self.daily_costs(days: 30)
83
+ where(tracked_at: days.days.ago..)
84
+ .group("DATE(tracked_at)")
85
+ .sum(:total_cost)
86
+ .transform_keys(&:to_s)
56
87
  end
57
88
  end
58
89
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Logging
5
+ PREFIX = "[LlmCostTracker]"
6
+
7
+ class << self
8
+ def debug(message)
9
+ log(:debug, message)
10
+ end
11
+
12
+ def info(message)
13
+ log(:info, message)
14
+ end
15
+
16
+ def warn(message)
17
+ log(:warn, message)
18
+ end
19
+
20
+ def log(level, message)
21
+ message = prefixed(message)
22
+
23
+ if rails_logger
24
+ rails_logger.public_send(level, message)
25
+ else
26
+ Kernel.warn(message)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def prefixed(message)
33
+ message = message.to_s
34
+ return message if message.start_with?(PREFIX)
35
+
36
+ "#{PREFIX} #{message}"
37
+ end
38
+
39
+ def rails_logger
40
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
41
+ end
42
+ end
43
+ end
44
+ end