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
@@ -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
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "uri"
4
-
5
- module LlmCostTracker
6
- module RequestUrl
7
- class << self
8
- def label(value)
9
- uri = URI.parse(value.to_s)
10
- uri.query = nil
11
- uri.fragment = nil
12
- uri.user = nil if uri.respond_to?(:user=)
13
- uri.password = nil if uri.respond_to?(:password=)
14
- uri.to_s
15
- rescue URI::InvalidURIError
16
- value.to_s.split("?", 2).first
17
- end
18
- end
19
- end
20
- end