llm_cost_tracker 0.7.0 → 0.7.1

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +11 -9
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +28 -35
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  89. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  91. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  92. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  93. data/lib/llm_cost_tracker/parsers.rb +20 -0
  94. data/lib/llm_cost_tracker/prices.json +52 -11
  95. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  96. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  97. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  98. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  99. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  100. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  101. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  102. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  104. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  105. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  106. data/lib/llm_cost_tracker/pricing.rb +33 -32
  107. data/lib/llm_cost_tracker/railtie.rb +7 -8
  108. data/lib/llm_cost_tracker/report/data.rb +72 -0
  109. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  110. data/lib/llm_cost_tracker/report.rb +8 -8
  111. data/lib/llm_cost_tracker/retention.rb +27 -10
  112. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  113. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  114. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  115. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  116. data/lib/llm_cost_tracker/tracker.rb +38 -70
  117. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  118. data/lib/llm_cost_tracker/version.rb +1 -1
  119. data/lib/llm_cost_tracker.rb +56 -78
  120. data/lib/tasks/llm_cost_tracker.rake +18 -13
  121. metadata +54 -58
  122. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  123. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  124. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  125. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  126. data/lib/llm_cost_tracker/cost.rb +0 -12
  127. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  128. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  129. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  130. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  131. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  132. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  133. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  134. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  135. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  136. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  137. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  138. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  139. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  140. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  141. data/lib/llm_cost_tracker/period_total.rb +0 -9
  142. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  143. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  144. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  145. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  146. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  147. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  148. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  149. data/lib/llm_cost_tracker/report_data.rb +0 -94
  150. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  151. data/lib/llm_cost_tracker/request_url.rb +0 -20
  152. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  153. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  154. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  155. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  156. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  157. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  158. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  159. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  160. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  161. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  162. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  163. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  164. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  165. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  166. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  167. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  168. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  169. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  170. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  171. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  172. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "yaml"
6
+
7
+ module LlmCostTracker
8
+ module Pricing
9
+ module Sync
10
+ class RegistryWriter
11
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
12
+
13
+ def call(path:, registry:)
14
+ FileUtils.mkdir_p(File.dirname(path))
15
+ payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
16
+ temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
17
+ File.write(temp_path, payload)
18
+ File.rename(temp_path, path)
19
+ ensure
20
+ FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
21
+ end
22
+
23
+ private
24
+
25
+ def yaml_file?(path)
26
+ YAML_EXTENSIONS.include?(File.extname(path).downcase)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "date"
5
+ require "json"
6
+ require "rubygems"
7
+
8
+ require_relative "registry"
9
+ require_relative "sync/fetcher"
10
+ require_relative "sync/registry_diff"
11
+ require_relative "sync/registry_loader"
12
+ require_relative "sync/registry_writer"
13
+
14
+ module LlmCostTracker
15
+ module Pricing
16
+ module Sync
17
+ DEFAULT_OUTPUT_PATH = "config/llm_cost_tracker_prices.yml"
18
+ DEFAULT_REMOTE_URL =
19
+ "https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
20
+ SUPPORTED_SCHEMA_VERSION = 1
21
+
22
+ RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
23
+ CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
24
+
25
+ class << self
26
+ def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
27
+ output = env["OUTPUT"].to_s.strip.presence
28
+ return output if output
29
+
30
+ prices_file = config.prices_file
31
+ return prices_file.to_s if prices_file
32
+
33
+ default_output_path
34
+ end
35
+
36
+ def configured_remote_url(env: ENV)
37
+ env["URL"].to_s.strip.presence || DEFAULT_REMOTE_URL
38
+ end
39
+
40
+ def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
41
+ today: Date.today)
42
+ current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
43
+ response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
44
+
45
+ if response.not_modified
46
+ return refresh_result(path, url, response, current, current, written: false, not_modified: true)
47
+ end
48
+
49
+ remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
50
+ RegistryWriter.new.call(path: path, registry: remote) unless preview
51
+ refresh_result(path, url, response, current, remote, written: !preview, not_modified: false)
52
+ end
53
+
54
+ def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
55
+ current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
56
+ response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
57
+
58
+ if response.not_modified
59
+ return CheckResult.new(
60
+ path: path,
61
+ source_url: url,
62
+ source_version: response.source_version,
63
+ changes: {},
64
+ up_to_date: true
65
+ )
66
+ end
67
+
68
+ remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
69
+ changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
70
+
71
+ CheckResult.new(
72
+ path: path,
73
+ source_url: url,
74
+ source_version: response.source_version,
75
+ changes: changes,
76
+ up_to_date: changes.empty?
77
+ )
78
+ end
79
+
80
+ private
81
+
82
+ def default_output_path
83
+ if Rails.root
84
+ Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
85
+ else
86
+ DEFAULT_OUTPUT_PATH
87
+ end
88
+ end
89
+
90
+ def normalize_remote_registry(body, url:, response:, today:)
91
+ registry = parse_registry(body)
92
+ metadata = registry.fetch("metadata", {})
93
+ raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
94
+
95
+ schema_version = Integer(metadata.fetch("schema_version", 1))
96
+ if schema_version > SUPPORTED_SCHEMA_VERSION
97
+ raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
98
+ end
99
+
100
+ min_gem_version = metadata["min_gem_version"]
101
+ if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
102
+ raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
103
+ end
104
+
105
+ models = registry.fetch("models", {})
106
+ Registry.normalize_price_table(models)
107
+
108
+ registry.merge(
109
+ "metadata" => metadata.merge(
110
+ "schema_version" => schema_version,
111
+ "updated_at" => metadata["updated_at"] || today.iso8601,
112
+ "source_url" => url,
113
+ "source_version" => response.source_version
114
+ ),
115
+ "models" => models
116
+ )
117
+ rescue ArgumentError, TypeError => e
118
+ raise Error, "Unable to load remote pricing snapshot: #{e.message}"
119
+ end
120
+
121
+ def parse_registry(body)
122
+ registry = JSON.parse(body.to_s)
123
+ raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
124
+
125
+ registry
126
+ rescue JSON::ParserError => e
127
+ raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
128
+ end
129
+
130
+ def refresh_result(path, url, response, current, remote, written:, not_modified:)
131
+ RefreshResult.new(
132
+ path: path,
133
+ source_url: url,
134
+ source_version: response.source_version,
135
+ changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
136
+ written: written,
137
+ not_modified: not_modified
138
+ )
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+
5
+ module LlmCostTracker
6
+ module Pricing
7
+ class Unknown
8
+ MUTEX = Mutex.new
9
+
10
+ class << self
11
+ def handle!(model)
12
+ model = model.to_s.presence || "unknown"
13
+
14
+ case LlmCostTracker.configuration.unknown_pricing_behavior
15
+ when :ignore
16
+ nil
17
+ when :warn
18
+ warn_missing(model)
19
+ when :raise
20
+ raise UnknownPricingError.new(model: model)
21
+ end
22
+ end
23
+
24
+ def reset!
25
+ MUTEX.synchronize { @warned_models = Set.new }
26
+ end
27
+
28
+ private
29
+
30
+ def warn_missing(model)
31
+ should_warn = MUTEX.synchronize do
32
+ @warned_models ||= Set.new
33
+ @warned_models.add?(model)
34
+ end
35
+ return unless should_warn
36
+
37
+ Logging.warn(
38
+ "No pricing configured for model #{model.inspect}. " \
39
+ "Cost and budget guardrails will be skipped for this event. " \
40
+ "Add a pricing_overrides entry or set unknown_pricing_behavior."
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,67 +1,68 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/hash/keys"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ require_relative "pricing/components"
7
+ require_relative "pricing/registry"
3
8
  require_relative "pricing/lookup"
4
9
  require_relative "pricing/effective_prices"
5
10
  require_relative "pricing/explainer"
6
11
 
7
12
  module LlmCostTracker
8
13
  module Pricing
9
- PRICES = PriceRegistry.builtin_prices
14
+ STANDARD_MODE_VALUES = %w[auto default standard standard_only].freeze
15
+ private_constant :STANDARD_MODE_VALUES
10
16
 
11
17
  class << self
12
- def cost_for(provider:, model:, input_tokens:, output_tokens:, cache_read_input_tokens: 0,
13
- cache_write_input_tokens: 0, pricing_mode: nil)
18
+ def normalize_mode(value)
19
+ mode = value.to_s.strip.presence
20
+ return nil unless mode
21
+
22
+ mode = mode.tr("-", "_")
23
+ STANDARD_MODE_VALUES.include?(mode) ? nil : mode
24
+ end
25
+
26
+ def cost_for(provider:, model:, token_usage:, pricing_mode: nil)
14
27
  prices = lookup(provider: provider, model: model)
15
28
  return nil unless prices
16
29
 
17
- usage = UsageBreakdown.build(
18
- input_tokens: input_tokens,
19
- output_tokens: output_tokens,
20
- cache_read_input_tokens: cache_read_input_tokens,
21
- cache_write_input_tokens: cache_write_input_tokens
22
- )
23
- costs = calculate_costs(usage, prices, pricing_mode: pricing_mode)
30
+ costs = calculate_costs(token_usage, prices, pricing_mode: pricing_mode)
24
31
  return nil unless costs
25
32
 
26
- Cost.new(
27
- input_cost: costs[:input].round(8),
28
- cache_read_input_cost: costs[:cache_read_input].round(8),
29
- cache_write_input_cost: costs[:cache_write_input].round(8),
30
- output_cost: costs[:output].round(8),
31
- total_cost: costs.values.sum.round(8),
32
- currency: "USD"
33
- )
33
+ values = COMPONENTS.to_h do |component|
34
+ [component.cost_key, costs.fetch(component.price_key).round(8)]
35
+ end
36
+
37
+ values.merge(total_cost: costs.values.sum.round(8))
34
38
  end
35
39
 
36
40
  def lookup(provider:, model:)
37
41
  Lookup.call(provider: provider, model: model)&.prices
38
42
  end
39
43
 
40
- def explain(provider:, model:, input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0,
41
- cache_write_input_tokens: 0, pricing_mode: nil)
44
+ def explain(provider:, model:, token_usage:, pricing_mode: nil)
42
45
  Explainer.call(
43
46
  provider: provider,
44
47
  model: model,
45
- input_tokens: input_tokens,
46
- output_tokens: output_tokens,
47
- cache_read_input_tokens: cache_read_input_tokens,
48
- cache_write_input_tokens: cache_write_input_tokens,
48
+ token_usage: token_usage,
49
49
  pricing_mode: pricing_mode
50
50
  )
51
51
  end
52
52
 
53
+ def stored_cost_attributes(attributes)
54
+ attributes.to_h.symbolize_keys.slice(*COST_KEYS).compact
55
+ end
56
+
53
57
  private
54
58
 
55
59
  def calculate_costs(usage, prices, pricing_mode:)
56
60
  effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
57
- return nil unless effective.complete?
58
-
59
- {
60
- input: token_cost(usage.input_tokens, effective.input),
61
- cache_read_input: token_cost(usage.cache_read_input_tokens, effective.cache_read_input),
62
- cache_write_input: token_cost(usage.cache_write_input_tokens, effective.cache_write_input),
63
- output: token_cost(usage.output_tokens, effective.output)
64
- }
61
+ return nil if effective.value?(nil)
62
+
63
+ usage.price_quantities.to_h do |key, tokens|
64
+ [key, token_cost(tokens, effective.fetch(key))]
65
+ end
65
66
  end
66
67
 
67
68
  def token_cost(tokens, per_million_price)
@@ -2,13 +2,19 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  class Railtie < Rails::Railtie
5
+ initializer "llm_cost_tracker.app_models_autoload_paths", before: :set_autoload_paths do |app|
6
+ models_path = File.expand_path("../../app/models", __dir__)
7
+ app.config.autoload_paths << models_path unless app.config.autoload_paths.include?(models_path)
8
+ app.config.eager_load_paths << models_path unless app.config.eager_load_paths.include?(models_path)
9
+ end
10
+
5
11
  generators do
6
12
  require_relative "generators/llm_cost_tracker/add_ingestion_generator"
7
13
  require_relative "generators/llm_cost_tracker/add_period_totals_generator"
8
14
  require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
9
15
  require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
10
16
  require_relative "generators/llm_cost_tracker/add_streaming_generator"
11
- require_relative "generators/llm_cost_tracker/add_usage_breakdown_generator"
17
+ require_relative "generators/llm_cost_tracker/add_token_usage_generator"
12
18
  require_relative "generators/llm_cost_tracker/install_generator"
13
19
  require_relative "generators/llm_cost_tracker/prices_generator"
14
20
  require_relative "generators/llm_cost_tracker/upgrade_cost_precision_generator"
@@ -18,12 +24,5 @@ module LlmCostTracker
18
24
  rake_tasks do
19
25
  load File.expand_path("../tasks/llm_cost_tracker.rake", __dir__)
20
26
  end
21
-
22
- initializer "llm_cost_tracker.configure" do
23
- ActiveSupport.on_load(:active_record) do
24
- require_relative "llm_api_call"
25
- require_relative "storage/active_record_store"
26
- end
27
- end
28
27
  end
29
28
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/integer/time"
4
+
5
+ require_relative "../ledger"
6
+
7
+ module LlmCostTracker
8
+ class Report
9
+ Data = ::Data.define(
10
+ :days,
11
+ :from_time,
12
+ :to_time,
13
+ :total_cost,
14
+ :requests_count,
15
+ :average_latency_ms,
16
+ :unknown_pricing_count,
17
+ :cost_by_provider,
18
+ :cost_by_model,
19
+ :cost_by_tags,
20
+ :top_calls
21
+ )
22
+
23
+ class Data
24
+ DEFAULT_DAYS = 30
25
+ TOP_LIMIT = 5
26
+
27
+ def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
28
+ days = days.to_i
29
+ days = DEFAULT_DAYS unless days.positive?
30
+ unless breakdown_limit.nil?
31
+ breakdown_limit = breakdown_limit.to_i
32
+ breakdown_limit = nil unless breakdown_limit.positive?
33
+ end
34
+ from = now - days.days
35
+ scope = Ledger::Call.where(tracked_at: from..now)
36
+ tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
37
+
38
+ new(
39
+ days: days,
40
+ from_time: from,
41
+ to_time: now,
42
+ total_cost: scope.sum(:total_cost).to_f,
43
+ requests_count: scope.count,
44
+ average_latency_ms: average_latency_ms(scope),
45
+ unknown_pricing_count: scope.where(total_cost: nil).count,
46
+ cost_by_provider: scope.cost_by_provider(limit: breakdown_limit).to_a,
47
+ cost_by_model: scope.cost_by_model(limit: breakdown_limit).to_a,
48
+ cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
49
+ top_calls: top_calls(scope)
50
+ )
51
+ end
52
+
53
+ def self.average_latency_ms(scope)
54
+ scope.average(:latency_ms)&.to_f
55
+ end
56
+
57
+ def self.cost_by_tags(scope, keys, limit:)
58
+ keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] }
59
+ end
60
+
61
+ def self.top_calls(scope)
62
+ scope
63
+ .where.not(total_cost: nil)
64
+ .order(total_cost: :desc)
65
+ .limit(TOP_LIMIT)
66
+ .to_a
67
+ end
68
+
69
+ private_class_method :average_latency_ms, :cost_by_tags, :top_calls
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Report
5
+ class Formatter
6
+ TOP_LIMIT = 5
7
+ NAME_COLUMN_WIDTH = 28
8
+ TOP_CALL_COLUMN_WIDTH = 32
9
+
10
+ def initialize(data)
11
+ @data = data
12
+ end
13
+
14
+ def to_s
15
+ lines = ["LLM Cost Report (last #{@data.days} days)", ""]
16
+ append_summary(lines)
17
+ append_cost_section(lines, "By provider", @data.cost_by_provider)
18
+ append_cost_section(lines, "By model", @data.cost_by_model)
19
+ append_tag_sections(lines)
20
+ append_top_calls(lines)
21
+ lines.join("\n")
22
+ end
23
+
24
+ private
25
+
26
+ def append_summary(lines)
27
+ lines << "Total cost: #{money(@data.total_cost)}"
28
+ lines << "Requests: #{@data.requests_count}"
29
+ lines << "Avg latency: #{average_latency}"
30
+ lines << "Unknown pricing: #{@data.unknown_pricing_count}"
31
+ end
32
+
33
+ def append_cost_section(lines, title, rows)
34
+ lines << ""
35
+ lines << "#{title}:"
36
+ return lines << " none" if rows.empty?
37
+
38
+ rows.first(TOP_LIMIT).each do |row|
39
+ lines << " #{row.name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(row.total_cost)}"
40
+ end
41
+ end
42
+
43
+ def append_tag_sections(lines)
44
+ @data.cost_by_tags.each do |tag_key, rows|
45
+ append_cost_section(lines, "By tag (#{tag_key})", rows)
46
+ end
47
+ end
48
+
49
+ def append_top_calls(lines)
50
+ lines << ""
51
+ lines << "Top expensive calls:"
52
+ return lines << " none" if @data.top_calls.empty?
53
+
54
+ @data.top_calls.first(TOP_LIMIT).each do |call|
55
+ label = "#{call.provider}/#{call.model}"
56
+ lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
57
+ end
58
+ end
59
+
60
+ def average_latency
61
+ @data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
62
+ end
63
+
64
+ def money(value)
65
+ "$#{format('%.6f', value.to_f)}"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,28 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "report_data"
4
- require_relative "report_formatter"
3
+ require_relative "report/data"
4
+ require_relative "report/formatter"
5
5
 
6
6
  module LlmCostTracker
7
7
  class Report
8
8
  class << self
9
- def generate(days: ReportData::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
10
- report_data = ReportData.build(
9
+ def generate(days: Data::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
10
+ report_data = Data.build(
11
11
  days: days,
12
12
  now: now,
13
13
  tag_breakdowns: tag_breakdowns,
14
- breakdown_limit: ReportFormatter::TOP_LIMIT
14
+ breakdown_limit: Formatter::TOP_LIMIT
15
15
  )
16
16
 
17
- ReportFormatter.new(report_data).to_s
17
+ Formatter.new(report_data).to_s
18
18
  rescue LoadError => e
19
19
  "Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
20
20
  rescue StandardError => e
21
21
  "Unable to build LLM cost report: #{e.class}: #{e.message}"
22
22
  end
23
23
 
24
- def data(days: ReportData::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
25
- ReportData.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
24
+ def data(days: Data::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
25
+ Data.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
26
26
  end
27
27
  end
28
28
  end
@@ -6,22 +6,23 @@ module LlmCostTracker
6
6
 
7
7
  class << self
8
8
  def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
9
- batch_size = normalized_batch_size(batch_size)
9
+ batch_size = batch_size.to_i
10
+ raise ArgumentError, "batch_size must be positive: #{batch_size.inspect}" unless batch_size.positive?
11
+
10
12
  cutoff = resolve_cutoff(older_than, now)
11
- require_relative "storage/active_record_backend"
13
+ require_relative "ledger"
12
14
 
13
- Storage::ActiveRecordBackend.prune(cutoff: cutoff, batch_size: batch_size)
15
+ deleted = 0
16
+ loop do
17
+ batch = prune_batch(cutoff, batch_size)
18
+ deleted += batch
19
+ break if batch < batch_size
20
+ end
21
+ deleted
14
22
  end
15
23
 
16
24
  private
17
25
 
18
- def normalized_batch_size(value)
19
- value = value.to_i
20
- raise ArgumentError, "batch_size must be positive: #{value.inspect}" unless value.positive?
21
-
22
- value
23
- end
24
-
25
26
  def resolve_cutoff(older_than, now)
26
27
  cutoff = case older_than
27
28
  when Time, DateTime then older_than.utc
@@ -46,6 +47,22 @@ module LlmCostTracker
46
47
 
47
48
  now - (days * 86_400)
48
49
  end
50
+
51
+ def prune_batch(cutoff, batch_size)
52
+ LlmCostTracker::Ledger::Call.transaction do
53
+ rows = LlmCostTracker::Ledger::Call
54
+ .where(tracked_at: ...cutoff)
55
+ .order(:id)
56
+ .limit(batch_size)
57
+ .lock
58
+ .pluck(:id, :tracked_at, :total_cost)
59
+ next 0 if rows.empty?
60
+
61
+ deleted = LlmCostTracker::Ledger::Call.where(id: rows.map(&:first)).delete_all
62
+ LlmCostTracker::Ledger::Rollups.decrement!(rows) if deleted.positive?
63
+ deleted
64
+ end
65
+ end
49
66
  end
50
67
  end
51
68
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require "active_support/isolated_execution_state"
5
+
6
+ module LlmCostTracker
7
+ module Tags
8
+ module Context
9
+ KEY = :llm_cost_tracker_tags
10
+
11
+ class << self
12
+ def with(tags)
13
+ stack = ActiveSupport::IsolatedExecutionState[KEY] || []
14
+ ActiveSupport::IsolatedExecutionState[KEY] = stack + [(tags || {}).deep_dup.to_h]
15
+ yield
16
+ ensure
17
+ ActiveSupport::IsolatedExecutionState[KEY] = stack
18
+ end
19
+
20
+ def tags
21
+ default_tags = LlmCostTracker.configuration.default_tags
22
+ default_tags = default_tags.call if default_tags.respond_to?(:call)
23
+
24
+ (default_tags || {}).deep_dup.to_h.merge(
25
+ (ActiveSupport::IsolatedExecutionState[KEY] || []).reduce({}) { |merged, tags| merged.merge(tags) }
26
+ )
27
+ end
28
+
29
+ def clear!
30
+ ActiveSupport::IsolatedExecutionState[KEY] = []
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Tags
5
+ module Key
6
+ PATTERN = /\A[\w.-]+\z/
7
+
8
+ class << self
9
+ def validate!(key, error_class: ArgumentError)
10
+ key = key.to_s
11
+ return key if key.match?(PATTERN)
12
+
13
+ raise error_class, "invalid tag key: #{key.inspect}"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end