llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  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 +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  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 +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  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 +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ module Sync
6
+ module RegistryDiff
7
+ class << self
8
+ def call(current_models, updated_models)
9
+ current_models = normalize_models(current_models)
10
+ updated_models = normalize_models(updated_models)
11
+
12
+ (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
13
+ fields = price_field_changes(current_models[model], updated_models[model])
14
+ changes[model] = fields if fields.any?
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def price_field_changes(current_entry, updated_entry)
21
+ current_price = comparable_price(current_entry)
22
+ updated_price = comparable_price(updated_entry)
23
+
24
+ (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
25
+ from = current_price[field]
26
+ to = updated_price[field]
27
+ next if from == to
28
+
29
+ changes[field] = { "from" => from, "to" => to }
30
+ end
31
+ end
32
+
33
+ def comparable_price(entry)
34
+ normalize_hash(entry).slice(*Registry::PRICE_KEYS)
35
+ end
36
+
37
+ def normalize_models(models)
38
+ normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
39
+ end
40
+
41
+ def normalize_hash(hash)
42
+ return {} if hash.nil?
43
+ raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
44
+
45
+ hash.each_with_object({}) do |(key, value), normalized|
46
+ normalized[key.to_s] = value
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ require_relative "../registry"
7
+
8
+ module LlmCostTracker
9
+ module Pricing
10
+ module Sync
11
+ class RegistryLoader
12
+ YAML_EXTENSIONS = %w[.yml .yaml].freeze
13
+
14
+ def call(path:, seed_path:)
15
+ source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
16
+ normalize_registry(load_registry_file(source_path))
17
+ rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
18
+ raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
19
+ end
20
+
21
+ private
22
+
23
+ def load_registry_file(path)
24
+ if File.size(path) > Registry::MAX_FILE_BYTES
25
+ raise ArgumentError, "pricing registry exceeds #{Registry::MAX_FILE_BYTES} bytes"
26
+ end
27
+
28
+ contents = File.read(path)
29
+ registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
30
+ raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
31
+
32
+ registry
33
+ end
34
+
35
+ def normalize_registry(registry)
36
+ {
37
+ "metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
38
+ "models" => normalize_models(registry.fetch("models", {}))
39
+ }
40
+ end
41
+
42
+ def normalize_models(models)
43
+ normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
44
+ normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
45
+ end
46
+ end
47
+
48
+ def normalize_hash(hash, label:)
49
+ return {} if hash.nil?
50
+ raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
51
+
52
+ hash.each_with_object({}) do |(key, value), normalized|
53
+ normalized[key.to_s] = value
54
+ end
55
+ end
56
+
57
+ def yaml_file?(path)
58
+ YAML_EXTENSIONS.include?(File.extname(path).downcase)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -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,159 @@
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(
47
+ path: path,
48
+ url: url,
49
+ response: response,
50
+ current: current,
51
+ remote: current,
52
+ written: false,
53
+ not_modified: true
54
+ )
55
+ end
56
+
57
+ remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
58
+ RegistryWriter.new.call(path: path, registry: remote) unless preview
59
+ refresh_result(
60
+ path: path,
61
+ url: url,
62
+ response: response,
63
+ current: current,
64
+ remote: remote,
65
+ written: !preview,
66
+ not_modified: false
67
+ )
68
+ end
69
+
70
+ def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
71
+ current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
72
+ response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
73
+
74
+ if response.not_modified
75
+ return CheckResult.new(
76
+ path: path,
77
+ source_url: url,
78
+ source_version: response.source_version,
79
+ changes: {},
80
+ up_to_date: true
81
+ )
82
+ end
83
+
84
+ remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
85
+ changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
86
+
87
+ CheckResult.new(
88
+ path: path,
89
+ source_url: url,
90
+ source_version: response.source_version,
91
+ changes: changes,
92
+ up_to_date: changes.empty?
93
+ )
94
+ end
95
+
96
+ private
97
+
98
+ def default_output_path
99
+ if Rails.root
100
+ Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
101
+ else
102
+ DEFAULT_OUTPUT_PATH
103
+ end
104
+ end
105
+
106
+ def normalize_remote_registry(body, url:, response:, today:)
107
+ registry = parse_registry(body)
108
+ metadata = registry.fetch("metadata", {})
109
+ raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
110
+
111
+ schema_version = Integer(metadata.fetch("schema_version", 1))
112
+ if schema_version > SUPPORTED_SCHEMA_VERSION
113
+ raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
114
+ end
115
+
116
+ min_gem_version = metadata["min_gem_version"]
117
+ if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
118
+ raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
119
+ end
120
+
121
+ models = registry.fetch("models", {})
122
+ Registry.normalize_price_table(models)
123
+
124
+ registry.merge(
125
+ "metadata" => metadata.merge(
126
+ "schema_version" => schema_version,
127
+ "updated_at" => metadata["updated_at"] || today.iso8601,
128
+ "source_url" => url,
129
+ "source_version" => response.source_version
130
+ ),
131
+ "models" => models
132
+ )
133
+ rescue ArgumentError, TypeError => e
134
+ raise Error, "Unable to load remote pricing snapshot: #{e.message}"
135
+ end
136
+
137
+ def parse_registry(body)
138
+ registry = JSON.parse(body.to_s)
139
+ raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
140
+
141
+ registry
142
+ rescue JSON::ParserError => e
143
+ raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
144
+ end
145
+
146
+ def refresh_result(path:, url:, response:, current:, remote:, written:, not_modified:)
147
+ RefreshResult.new(
148
+ path: path,
149
+ source_url: url,
150
+ source_version: response.source_version,
151
+ changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
152
+ written: written,
153
+ not_modified: not_modified
154
+ )
155
+ end
156
+ end
157
+ end
158
+ end
159
+ 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