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
@@ -1,144 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "monitor"
5
- require "yaml"
6
-
7
- require_relative "logging"
8
-
9
- module LlmCostTracker
10
- module PriceRegistry
11
- DEFAULT_PRICES_PATH = File.expand_path("prices.json", __dir__)
12
- EMPTY_PRICES = {}.freeze
13
- PRICE_KEYS = %w[input output cache_read_input cache_write_input].freeze
14
- METADATA_KEYS = %w[_source _source_version _fetched_at _updated _notes _validator_override].freeze
15
- MAX_FILE_BYTES = 2_097_152
16
- MUTEX = Monitor.new
17
-
18
- class << self
19
- def builtin_prices
20
- @builtin_prices ||= MUTEX.synchronize do
21
- @builtin_prices || normalize_price_table(raw_registry.fetch("models", {})).freeze
22
- end
23
- end
24
-
25
- def metadata
26
- @metadata ||= MUTEX.synchronize { @metadata || raw_registry.fetch("metadata", {}).freeze }
27
- end
28
-
29
- def file_metadata(path)
30
- return {} unless path
31
-
32
- registry = load_price_file(path.to_s)
33
- raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
34
-
35
- metadata = registry.fetch("metadata", {})
36
- raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
37
-
38
- metadata
39
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
40
- raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
41
- end
42
-
43
- def normalize_price_table(table)
44
- normalize_price_entries(table, context: "price table")
45
- end
46
-
47
- def file_prices(path)
48
- return EMPTY_PRICES unless path
49
-
50
- path = path.to_s
51
- cache_key = [path, File.mtime(path).to_f]
52
- cached = @file_prices_cache
53
- return cached[:value] if cached && cached[:key] == cache_key
54
-
55
- MUTEX.synchronize do
56
- cached = @file_prices_cache
57
- return cached[:value] if cached && cached[:key] == cache_key
58
-
59
- value = normalize_file_prices(price_file_models(load_price_file(path)), path: path).freeze
60
- @file_prices_cache = { key: cache_key, value: value }.freeze
61
- value
62
- end
63
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
64
- raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
65
- end
66
-
67
- private
68
-
69
- def raw_registry
70
- @raw_registry ||= MUTEX.synchronize do
71
- @raw_registry || JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
72
- end
73
- end
74
-
75
- def normalize_price_entry(price)
76
- price.each_with_object({}) do |(key, value), normalized|
77
- key = key.to_s
78
- normalized[key.to_sym] = Float(value) if price_key?(key)
79
- end
80
- end
81
-
82
- def normalize_file_prices(table, path:)
83
- normalize_price_entries(table, context: path)
84
- end
85
-
86
- def normalize_price_entries(table, context:)
87
- table = {} if table.nil?
88
- raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
89
-
90
- table.each_with_object({}) do |(model, price), normalized|
91
- price = validate_price_entry(price, model: model, context: context)
92
- warn_unknown_keys(model, price, context)
93
- normalized[model.to_s] = normalize_price_entry(price)
94
- end
95
- end
96
-
97
- def warn_unknown_keys(model, price, path)
98
- unknown_keys = price.keys.map(&:to_s).reject do |key|
99
- price_key?(key) || METADATA_KEYS.include?(key)
100
- end
101
- return if unknown_keys.empty?
102
-
103
- Logging.warn(
104
- "Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
105
- "ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
106
- )
107
- end
108
-
109
- def price_key?(key)
110
- return true if PRICE_KEYS.include?(key)
111
-
112
- PRICE_KEYS.any? do |base_key|
113
- key.end_with?("_#{base_key}") && key.delete_suffix("_#{base_key}") != ""
114
- end
115
- end
116
-
117
- def load_price_file(path)
118
- raise ArgumentError, "prices_file exceeds #{MAX_FILE_BYTES} bytes" if File.size(path) > MAX_FILE_BYTES
119
-
120
- contents = File.read(path)
121
- return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
122
-
123
- JSON.parse(contents)
124
- end
125
-
126
- def yaml_file?(path)
127
- %w[.yaml .yml].include?(File.extname(path).downcase)
128
- end
129
-
130
- def price_file_models(registry)
131
- raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
132
-
133
- registry.fetch("models", registry)
134
- end
135
-
136
- def validate_price_entry(price, model:, context:)
137
- return {} if price.nil?
138
- return price if price.is_a?(Hash)
139
-
140
- raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
141
- end
142
- end
143
- end
144
- end
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "net/http"
5
- require "openssl"
6
- require "time"
7
- require "uri"
8
-
9
- module LlmCostTracker
10
- module PriceSync
11
- class Fetcher
12
- Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
13
- def source_version
14
- etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
15
- end
16
- end
17
-
18
- USER_AGENT = "llm_cost_tracker price refresh"
19
- MAX_REDIRECTS = 5
20
- MAX_BODY_BYTES = 2_097_152
21
- OPEN_TIMEOUT = 5
22
- READ_TIMEOUT = 10
23
- WRITE_TIMEOUT = 10
24
-
25
- def get(url, etag: nil, redirects: 0)
26
- raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
27
-
28
- uri = URI.parse(url)
29
- raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
30
-
31
- request = Net::HTTP::Get.new(uri)
32
- request["User-Agent"] = USER_AGENT
33
- request["If-None-Match"] = etag if etag
34
-
35
- response, body = fetch_response(uri, request)
36
-
37
- case response
38
- when Net::HTTPSuccess
39
- build_response(response, body: body || limited_body(response), not_modified: false)
40
- when Net::HTTPNotModified
41
- build_response(response, body: nil, not_modified: true)
42
- when Net::HTTPRedirection
43
- location = response["location"]
44
- raise Error, "Redirect without location while fetching #{url}" if location.nil? || location.empty?
45
-
46
- get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
47
- else
48
- raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
49
- end
50
- rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
51
- raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
52
- end
53
-
54
- private
55
-
56
- def fetch_response(uri, request)
57
- body = nil
58
- response = Net::HTTP.start(
59
- uri.host,
60
- uri.port,
61
- use_ssl: uri.scheme == "https",
62
- open_timeout: OPEN_TIMEOUT,
63
- read_timeout: READ_TIMEOUT,
64
- write_timeout: WRITE_TIMEOUT
65
- ) do |http|
66
- http.request(request) do |streamed_response|
67
- body = limited_body(streamed_response) if streamed_response.is_a?(Net::HTTPSuccess)
68
- end
69
- end
70
-
71
- [response, body]
72
- end
73
-
74
- def limited_body(response)
75
- body = +""
76
- if response.respond_to?(:read_body)
77
- response.read_body do |chunk|
78
- chunk = chunk.to_s
79
- if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
80
- raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
81
- end
82
-
83
- body << chunk
84
- end
85
- else
86
- body = response.body.to_s
87
- end
88
- raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes" if body.bytesize > MAX_BODY_BYTES
89
-
90
- body
91
- end
92
-
93
- def build_response(response, not_modified:, body: response.body)
94
- Response.new(
95
- body: body,
96
- etag: response["etag"],
97
- last_modified: response["last-modified"],
98
- not_modified: not_modified,
99
- fetched_at: Time.now.utc.iso8601
100
- )
101
- end
102
- end
103
- end
104
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module PriceSync
5
- module RegistryDiff
6
- class << self
7
- def call(current_models, updated_models)
8
- current_models = normalize_models(current_models)
9
- updated_models = normalize_models(updated_models)
10
-
11
- (current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
12
- fields = price_field_changes(current_models[model], updated_models[model])
13
- changes[model] = fields if fields.any?
14
- end
15
- end
16
-
17
- private
18
-
19
- def price_field_changes(current_entry, updated_entry)
20
- current_price = comparable_price(current_entry)
21
- updated_price = comparable_price(updated_entry)
22
-
23
- (current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
24
- from = current_price[field]
25
- to = updated_price[field]
26
- next if from == to
27
-
28
- changes[field] = { "from" => from, "to" => to }
29
- end
30
- end
31
-
32
- def comparable_price(entry)
33
- normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
34
- end
35
-
36
- def normalize_models(models)
37
- normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
38
- end
39
-
40
- def normalize_hash(hash)
41
- return {} if hash.nil?
42
- raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
43
-
44
- hash.each_with_object({}) do |(key, value), normalized|
45
- normalized[key.to_s] = value
46
- end
47
- end
48
- end
49
- end
50
- end
51
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "yaml"
5
-
6
- require_relative "../price_registry"
7
-
8
- module LlmCostTracker
9
- module PriceSync
10
- class RegistryLoader
11
- YAML_EXTENSIONS = %w[.yml .yaml].freeze
12
-
13
- def call(path:, seed_path:)
14
- source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
15
- normalize_registry(load_registry_file(source_path))
16
- rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
17
- raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
18
- end
19
-
20
- private
21
-
22
- def load_registry_file(path)
23
- if File.size(path) > PriceRegistry::MAX_FILE_BYTES
24
- raise ArgumentError, "pricing registry exceeds #{PriceRegistry::MAX_FILE_BYTES} bytes"
25
- end
26
-
27
- contents = File.read(path)
28
- registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
29
- raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
30
-
31
- registry
32
- end
33
-
34
- def normalize_registry(registry)
35
- {
36
- "metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
37
- "models" => normalize_models(registry.fetch("models", {}))
38
- }
39
- end
40
-
41
- def normalize_models(models)
42
- normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
43
- normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
44
- end
45
- end
46
-
47
- def normalize_hash(hash, label:)
48
- return {} if hash.nil?
49
- raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
50
-
51
- hash.each_with_object({}) do |(key, value), normalized|
52
- normalized[key.to_s] = value
53
- end
54
- end
55
-
56
- def yaml_file?(path)
57
- YAML_EXTENSIONS.include?(File.extname(path).downcase)
58
- end
59
- end
60
- end
61
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "json"
5
- require "yaml"
6
-
7
- module LlmCostTracker
8
- module PriceSync
9
- class RegistryWriter
10
- YAML_EXTENSIONS = %w[.yml .yaml].freeze
11
-
12
- def call(path:, registry:)
13
- FileUtils.mkdir_p(File.dirname(path))
14
- payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
15
- temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
16
- File.write(temp_path, payload)
17
- File.rename(temp_path, path)
18
- ensure
19
- FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
20
- end
21
-
22
- private
23
-
24
- def yaml_file?(path)
25
- YAML_EXTENSIONS.include?(File.extname(path).downcase)
26
- end
27
- end
28
- end
29
- end
@@ -1,144 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "date"
4
- require "json"
5
- require "rubygems"
6
-
7
- require_relative "price_sync/fetcher"
8
- require_relative "price_sync/registry_diff"
9
- require_relative "price_sync/registry_loader"
10
- require_relative "price_sync/registry_writer"
11
-
12
- module LlmCostTracker
13
- module PriceSync
14
- DEFAULT_OUTPUT_PATH = "config/llm_cost_tracker_prices.yml"
15
- DEFAULT_REMOTE_URL =
16
- "https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
17
- SUPPORTED_SCHEMA_VERSION = 1
18
-
19
- RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
20
- CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
21
-
22
- class << self
23
- def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
24
- output = env["OUTPUT"].to_s.strip
25
- return output unless output.empty?
26
-
27
- prices_file = config.prices_file
28
- return prices_file.to_s if prices_file
29
-
30
- default_output_path
31
- end
32
-
33
- def configured_remote_url(env: ENV)
34
- url = env["URL"].to_s.strip
35
- url.empty? ? DEFAULT_REMOTE_URL : url
36
- end
37
-
38
- def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
39
- today: Date.today)
40
- current = load_current_registry(path)
41
- response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
42
-
43
- if response.not_modified
44
- return refresh_result(path, url, response, current, current, written: false, not_modified: true)
45
- end
46
-
47
- remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
48
- RegistryWriter.new.call(path: path, registry: remote) unless preview
49
- refresh_result(path, url, response, current, remote, written: !preview, not_modified: false)
50
- end
51
-
52
- def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
53
- current = load_current_registry(path)
54
- response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
55
-
56
- if response.not_modified
57
- return CheckResult.new(
58
- path: path,
59
- source_url: url,
60
- source_version: response.source_version,
61
- changes: {},
62
- up_to_date: true
63
- )
64
- end
65
-
66
- remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
67
- changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
68
-
69
- CheckResult.new(
70
- path: path,
71
- source_url: url,
72
- source_version: response.source_version,
73
- changes: changes,
74
- up_to_date: changes.empty?
75
- )
76
- end
77
-
78
- private
79
-
80
- def default_output_path
81
- if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
82
- Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
83
- else
84
- DEFAULT_OUTPUT_PATH
85
- end
86
- end
87
-
88
- def load_current_registry(path)
89
- RegistryLoader.new.call(path: path, seed_path: PriceRegistry::DEFAULT_PRICES_PATH)
90
- end
91
-
92
- def normalize_remote_registry(body, url:, response:, today:)
93
- registry = parse_registry(body)
94
- metadata = registry.fetch("metadata", {})
95
- raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
96
-
97
- schema_version = Integer(metadata.fetch("schema_version", 1))
98
- if schema_version > SUPPORTED_SCHEMA_VERSION
99
- raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
100
- end
101
-
102
- min_gem_version = metadata["min_gem_version"]
103
- if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
104
- raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
105
- end
106
-
107
- models = registry.fetch("models", {})
108
- PriceRegistry.normalize_price_table(models)
109
-
110
- registry.merge(
111
- "metadata" => metadata.merge(
112
- "schema_version" => schema_version,
113
- "updated_at" => metadata["updated_at"] || today.iso8601,
114
- "source_url" => url,
115
- "source_version" => response.source_version
116
- ),
117
- "models" => models
118
- )
119
- rescue ArgumentError, TypeError => e
120
- raise Error, "Unable to load remote pricing snapshot: #{e.message}"
121
- end
122
-
123
- def parse_registry(body)
124
- registry = JSON.parse(body.to_s)
125
- raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
126
-
127
- registry
128
- rescue JSON::ParserError => e
129
- raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
130
- end
131
-
132
- def refresh_result(path, url, response, current, remote, written:, not_modified:)
133
- RefreshResult.new(
134
- path: path,
135
- source_url: url,
136
- source_version: response.source_version,
137
- changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
138
- written: written,
139
- not_modified: not_modified
140
- )
141
- end
142
- end
143
- end
144
- end
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/core_ext/integer/time"
4
-
5
- module LlmCostTracker
6
- TopCall = Data.define(:provider, :model, :total_cost)
7
-
8
- ReportData = Data.define(
9
- :days,
10
- :from_time,
11
- :to_time,
12
- :total_cost,
13
- :requests_count,
14
- :average_latency_ms,
15
- :unknown_pricing_count,
16
- :cost_by_provider,
17
- :cost_by_model,
18
- :cost_by_tags,
19
- :top_calls
20
- )
21
-
22
- class ReportData
23
- DEFAULT_DAYS = 30
24
- TOP_LIMIT = 5
25
-
26
- def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
27
- require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
28
-
29
- days = normalized_days(days)
30
- breakdown_limit = normalized_limit(breakdown_limit)
31
- from = now - days.days
32
- scope = LlmApiCall.where(tracked_at: from..now)
33
- tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
34
-
35
- new(
36
- days: days,
37
- from_time: from,
38
- to_time: now,
39
- total_cost: scope.sum(:total_cost).to_f,
40
- requests_count: scope.count,
41
- average_latency_ms: average_latency_ms(scope),
42
- unknown_pricing_count: scope.where(total_cost: nil).count,
43
- cost_by_provider: cost_by(scope, :provider, limit: breakdown_limit),
44
- cost_by_model: cost_by(scope, :model, limit: breakdown_limit),
45
- cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
46
- top_calls: top_calls(scope)
47
- )
48
- end
49
-
50
- def self.normalized_days(days)
51
- days = days.to_i
52
- days.positive? ? days : DEFAULT_DAYS
53
- end
54
-
55
- def self.normalized_limit(limit)
56
- return nil if limit.nil?
57
-
58
- limit = limit.to_i
59
- limit.positive? ? limit : nil
60
- end
61
-
62
- def self.average_latency_ms(scope)
63
- return nil unless LlmApiCall.latency_column?
64
-
65
- scope.average(:latency_ms)&.to_f
66
- end
67
-
68
- def self.cost_by(scope, column, limit:)
69
- relation = scope.group(column)
70
- .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
71
-
72
- relation = relation.limit(limit) if limit
73
-
74
- relation
75
- .sum(:total_cost)
76
- .transform_values(&:to_f)
77
- .sort_by { |_name, cost| -cost }
78
- end
79
-
80
- def self.cost_by_tags(scope, keys, limit:)
81
- keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] }
82
- end
83
-
84
- def self.top_calls(scope)
85
- scope
86
- .where.not(total_cost: nil)
87
- .order(total_cost: :desc)
88
- .limit(TOP_LIMIT)
89
- .map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
90
- end
91
-
92
- private_class_method :normalized_days, :normalized_limit, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
93
- end
94
- end
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- class ReportFormatter
5
- TOP_LIMIT = 5
6
- NAME_COLUMN_WIDTH = 28
7
- TOP_CALL_COLUMN_WIDTH = 32
8
-
9
- def initialize(data)
10
- @data = data
11
- end
12
-
13
- def to_s
14
- lines = ["LLM Cost Report (last #{@data.days} days)", ""]
15
- append_summary(lines)
16
- append_cost_section(lines, "By provider", @data.cost_by_provider)
17
- append_cost_section(lines, "By model", @data.cost_by_model)
18
- append_tag_sections(lines)
19
- append_top_calls(lines)
20
- lines.join("\n")
21
- end
22
-
23
- private
24
-
25
- def append_summary(lines)
26
- lines << "Total cost: #{money(@data.total_cost)}"
27
- lines << "Requests: #{@data.requests_count}"
28
- lines << "Avg latency: #{average_latency}"
29
- lines << "Unknown pricing: #{@data.unknown_pricing_count}"
30
- end
31
-
32
- def append_cost_section(lines, title, rows)
33
- lines << ""
34
- lines << "#{title}:"
35
- return lines << " none" if rows.empty?
36
-
37
- rows.first(TOP_LIMIT).each do |name, cost|
38
- lines << " #{name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(cost)}"
39
- end
40
- end
41
-
42
- def append_tag_sections(lines)
43
- @data.cost_by_tags.each do |tag_key, rows|
44
- append_cost_section(lines, "By tag (#{tag_key})", rows)
45
- end
46
- end
47
-
48
- def append_top_calls(lines)
49
- lines << ""
50
- lines << "Top expensive calls:"
51
- return lines << " none" if @data.top_calls.empty?
52
-
53
- @data.top_calls.first(TOP_LIMIT).each do |call|
54
- label = "#{call.provider}/#{call.model}"
55
- lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
56
- end
57
- end
58
-
59
- def average_latency
60
- @data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
61
- end
62
-
63
- def money(value)
64
- "$#{format('%.6f', value.to_f)}"
65
- end
66
- end
67
- end