llm_cost_tracker 0.6.1 → 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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +13 -12
  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 -37
  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/config/routes.rb +1 -1
  43. data/lib/llm_cost_tracker/assets.rb +0 -6
  44. data/lib/llm_cost_tracker/budget.rb +10 -24
  45. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
  47. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  48. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  49. data/lib/llm_cost_tracker/configuration.rb +30 -45
  50. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  51. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -61
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  54. data/lib/llm_cost_tracker/doctor.rb +66 -79
  55. data/lib/llm_cost_tracker/engine.rb +0 -3
  56. data/lib/llm_cost_tracker/errors.rb +4 -15
  57. data/lib/llm_cost_tracker/event.rb +6 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +15 -14
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -21
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  67. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  69. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  70. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  71. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
  73. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  74. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  76. data/lib/llm_cost_tracker/integrations.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  78. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  79. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  80. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  81. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  82. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  84. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  88. data/lib/llm_cost_tracker/ledger.rb +13 -0
  89. data/lib/llm_cost_tracker/logging.rb +3 -6
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  92. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  94. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  95. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  96. data/lib/llm_cost_tracker/parsers.rb +20 -0
  97. data/lib/llm_cost_tracker/prices.json +52 -11
  98. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  99. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  100. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  101. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  102. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  103. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  106. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  107. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  108. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  109. data/lib/llm_cost_tracker/pricing.rb +33 -32
  110. data/lib/llm_cost_tracker/railtie.rb +7 -10
  111. data/lib/llm_cost_tracker/report/data.rb +72 -0
  112. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  113. data/lib/llm_cost_tracker/report.rb +8 -10
  114. data/lib/llm_cost_tracker/retention.rb +27 -10
  115. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  116. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  117. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  119. data/lib/llm_cost_tracker/tracker.rb +38 -70
  120. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +56 -90
  123. data/lib/tasks/llm_cost_tracker.rake +18 -13
  124. metadata +85 -99
  125. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  126. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  127. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -49
  128. data/lib/llm_cost_tracker/capture_verifier.rb +0 -71
  129. data/lib/llm_cost_tracker/configuration/storage_backend.rb +0 -26
  130. data/lib/llm_cost_tracker/cost.rb +0 -12
  131. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  132. data/lib/llm_cost_tracker/engine_compatibility.rb +0 -15
  133. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  136. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  137. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  138. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  139. data/lib/llm_cost_tracker/integrations/registry.rb +0 -73
  140. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  141. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  142. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  143. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  144. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  145. data/lib/llm_cost_tracker/period_grouping.rb +0 -69
  146. data/lib/llm_cost_tracker/period_total.rb +0 -9
  147. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  148. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  149. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  150. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  151. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  152. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  153. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  154. data/lib/llm_cost_tracker/report_data.rb +0 -94
  155. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  156. data/lib/llm_cost_tracker/request_url.rb +0 -20
  157. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -166
  158. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  159. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -165
  160. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  161. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  162. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  163. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  164. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  165. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -32
  166. data/lib/llm_cost_tracker/storage/dispatcher.rb +0 -45
  167. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -38
  168. data/lib/llm_cost_tracker/storage/registry.rb +0 -63
  169. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  170. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  171. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  172. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  173. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  174. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  175. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  176. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  177. data/lib/llm_cost_tracker/tags_column.rb +0 -103
  178. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  179. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  180. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module EventMetadata
5
- INTERNAL_TAG_KEYS = %w[
6
- cache_read_input_tokens
7
- cache_write_input_tokens
8
- hidden_output_tokens
9
- input_tokens
10
- output_tokens
11
- pricing_mode
12
- provider_response_id
13
- total_tokens
14
- ].freeze
15
-
16
- class << self
17
- def usage_data(input_tokens, output_tokens, metadata)
18
- metadata = metadata.to_h.symbolize_keys
19
- cache_read = first_integer(metadata, :cache_read_input_tokens)
20
- cache_write = first_integer(metadata, :cache_write_input_tokens)
21
- hidden_output = first_integer(metadata, :hidden_output_tokens)
22
- breakdown = UsageBreakdown.build(
23
- input_tokens: input_tokens,
24
- output_tokens: output_tokens,
25
- cache_read_input_tokens: cache_read,
26
- cache_write_input_tokens: cache_write,
27
- hidden_output_tokens: hidden_output
28
- )
29
-
30
- breakdown.to_h.merge(pricing_mode: normalized_pricing_mode(metadata[:pricing_mode])).compact
31
- end
32
-
33
- def tags(metadata)
34
- metadata.reject { |key, _value| INTERNAL_TAG_KEYS.include?(key.to_s) }
35
- end
36
-
37
- private
38
-
39
- def first_integer(metadata, *keys)
40
- keys.each { |key| return metadata[key].to_i unless metadata[key].nil? }
41
- 0
42
- end
43
-
44
- def normalized_pricing_mode(value)
45
- return nil if value.nil?
46
-
47
- mode = value.to_s.strip
48
- mode.empty? || mode == "standard" ? nil : mode
49
- end
50
- end
51
- end
52
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module LlmCostTracker
7
- module Generators
8
- class AddUsageBreakdownGenerator < Rails::Generators::Base
9
- include ActiveRecord::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- desc "Creates a migration to add usage and cost breakdown columns to llm_api_calls"
14
-
15
- def create_migration_file
16
- migration_template(
17
- "add_usage_breakdown_to_llm_api_calls.rb.erb",
18
- "db/migrate/add_usage_breakdown_to_llm_api_calls.rb"
19
- )
20
- end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
- end
28
- end
29
- end
@@ -1,29 +0,0 @@
1
- class AddUsageBreakdownToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
- def up
3
- unless column_exists?(:llm_api_calls, :cache_read_input_tokens)
4
- add_column :llm_api_calls, :cache_read_input_tokens, :integer, null: false, default: 0
5
- end
6
- unless column_exists?(:llm_api_calls, :cache_write_input_tokens)
7
- add_column :llm_api_calls, :cache_write_input_tokens, :integer, null: false, default: 0
8
- end
9
- unless column_exists?(:llm_api_calls, :hidden_output_tokens)
10
- add_column :llm_api_calls, :hidden_output_tokens, :integer, null: false, default: 0
11
- end
12
- unless column_exists?(:llm_api_calls, :cache_read_input_cost)
13
- add_column :llm_api_calls, :cache_read_input_cost, :decimal, precision: 20, scale: 8
14
- end
15
- unless column_exists?(:llm_api_calls, :cache_write_input_cost)
16
- add_column :llm_api_calls, :cache_write_input_cost, :decimal, precision: 20, scale: 8
17
- end
18
- add_column :llm_api_calls, :pricing_mode, :string unless column_exists?(:llm_api_calls, :pricing_mode)
19
- end
20
-
21
- def down
22
- remove_column :llm_api_calls, :pricing_mode if column_exists?(:llm_api_calls, :pricing_mode)
23
- remove_column :llm_api_calls, :cache_write_input_cost if column_exists?(:llm_api_calls, :cache_write_input_cost)
24
- remove_column :llm_api_calls, :cache_read_input_cost if column_exists?(:llm_api_calls, :cache_read_input_cost)
25
- remove_column :llm_api_calls, :hidden_output_tokens if column_exists?(:llm_api_calls, :hidden_output_tokens)
26
- remove_column :llm_api_calls, :cache_write_input_tokens if column_exists?(:llm_api_calls, :cache_write_input_tokens)
27
- remove_column :llm_api_calls, :cache_read_input_tokens if column_exists?(:llm_api_calls, :cache_read_input_tokens)
28
- end
29
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_record"
4
-
5
- module LlmCostTracker
6
- class InboxEvent < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_inbox_events"
8
- end
9
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_record"
4
-
5
- module LlmCostTracker
6
- class IngestorLease < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_ingestor_leases"
8
- end
9
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Integrations
5
- module ObjectReader
6
- module_function
7
-
8
- def first(object, *keys)
9
- keys.each do |key|
10
- value = read(object, key)
11
- return value unless value.nil?
12
- end
13
- nil
14
- end
15
-
16
- def nested(object, *path)
17
- path.reduce(object) do |current, key|
18
- return nil if current.nil?
19
-
20
- read(current, key)
21
- end
22
- end
23
-
24
- def read(object, key)
25
- return nil if object.nil?
26
-
27
- read_hash(object, key) || read_method(object, key) || read_index(object, key)
28
- end
29
-
30
- def integer(value)
31
- value.nil? ? 0 : value.to_i
32
- end
33
-
34
- def read_hash(object, key)
35
- return unless object.respond_to?(:key?)
36
-
37
- return object[key] if object.key?(key)
38
-
39
- string_key = key.to_s
40
- object[string_key] if object.key?(string_key)
41
- end
42
-
43
- def read_method(object, key)
44
- object.public_send(key) if object.respond_to?(key)
45
- end
46
-
47
- def read_index(object, key)
48
- return unless object.respond_to?(:[])
49
-
50
- object[key]
51
- rescue IndexError, NameError, TypeError
52
- nil
53
- end
54
- end
55
- end
56
- end
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "monitor"
4
-
5
- require_relative "../errors"
6
- require_relative "openai"
7
- require_relative "anthropic"
8
- require_relative "ruby_llm"
9
-
10
- module LlmCostTracker
11
- module Integrations
12
- module Registry
13
- DEFAULT_INTEGRATIONS = {
14
- openai: Openai,
15
- anthropic: Anthropic,
16
- ruby_llm: RubyLlm
17
- }.freeze
18
- MUTEX = Monitor.new
19
-
20
- module_function
21
-
22
- def register(name, integration)
23
- key = name.to_sym
24
- validate_integration!(integration)
25
- MUTEX.synchronize { @integrations = integrations.merge(key => integration).freeze }
26
- integration
27
- end
28
-
29
- def install!(names = LlmCostTracker.configuration.instrumented_integrations)
30
- normalize(names).each { |name| fetch(name).install }
31
- end
32
-
33
- def checks(names = LlmCostTracker.configuration.instrumented_integrations)
34
- return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
35
-
36
- normalize(names).map { |name| fetch(name).status }
37
- end
38
-
39
- def normalize(names)
40
- Array(names).flatten.map(&:to_sym).uniq
41
- end
42
-
43
- def fetch(name)
44
- integrations.fetch(name.to_sym) do
45
- message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
46
- raise LlmCostTracker::Error, message
47
- end
48
- end
49
-
50
- def names
51
- integrations.keys
52
- end
53
-
54
- def reset!
55
- MUTEX.synchronize { @integrations = DEFAULT_INTEGRATIONS.dup.freeze }
56
- end
57
-
58
- def integrations
59
- @integrations || MUTEX.synchronize { @integrations ||= DEFAULT_INTEGRATIONS.dup.freeze }
60
- end
61
-
62
- def validate_integration!(integration)
63
- return if integration.respond_to?(:install) && integration.respond_to?(:status)
64
-
65
- raise ArgumentError, "integration must respond to install and status"
66
- end
67
- end
68
-
69
- def self.register(name, integration) = Registry.register(name, integration)
70
- def self.install! = Registry.install!
71
- def self.checks = Registry.checks
72
- end
73
- end
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_record"
4
-
5
- require_relative "llm_api_call_metrics"
6
- require_relative "period_grouping"
7
- require_relative "tag_accessors"
8
- require_relative "tag_query"
9
- require_relative "tags_column"
10
-
11
- module LlmCostTracker
12
- class LlmApiCall < ActiveRecord::Base
13
- extend PeriodGrouping
14
- extend TagsColumn
15
- extend LlmApiCallMetrics
16
- include TagAccessors
17
-
18
- self.table_name = "llm_api_calls"
19
-
20
- scope :with_cost, -> { where.not(total_cost: nil) }
21
- scope :without_cost, -> { where(total_cost: nil) }
22
- scope :unknown_pricing, -> { without_cost }
23
- scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
24
- scope :streaming, -> { stream_column? ? where(stream: true) : none }
25
- scope :non_streaming, -> { stream_column? ? where(stream: [false, nil]) : all }
26
- scope :by_usage_source, ->(source) { usage_source_column? ? where(usage_source: source.to_s) : none }
27
- scope :with_provider_response_id, lambda {
28
- provider_response_id_column? ? where.not(provider_response_id: [nil, ""]) : none
29
- }
30
- scope :missing_provider_response_id, lambda {
31
- provider_response_id_column? ? where(provider_response_id: [nil, ""]) : none
32
- }
33
- scope :streaming_missing_usage, lambda {
34
- return none unless stream_column? && usage_source_column?
35
-
36
- where(stream: true).where(usage_source: ["unknown", nil])
37
- }
38
-
39
- scope :with_json_tags, lambda {
40
- if tags_json_column?
41
- where.not(tags: {})
42
- else
43
- where.not(tags: [nil, "", "{}"])
44
- end
45
- }
46
-
47
- scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
48
- scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
49
- scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
50
- scope :between, ->(from, to) { where(tracked_at: from..to) }
51
-
52
- def self.by_tag(key, value)
53
- by_tags(key => value)
54
- end
55
-
56
- def self.by_tags(tags)
57
- TagQuery.apply(self, tags)
58
- end
59
- end
60
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "tag_sql"
4
-
5
- module LlmCostTracker
6
- module LlmApiCallMetrics
7
- def total_cost
8
- sum(:total_cost).to_f
9
- end
10
-
11
- def total_tokens
12
- sum(:total_tokens).to_i
13
- end
14
-
15
- def cost_by_model
16
- group(:model).sum(:total_cost)
17
- end
18
-
19
- def cost_by_provider
20
- group(:provider).sum(:total_cost)
21
- end
22
-
23
- def group_by_tag(key)
24
- group(Arel.sql(tag_value_expression(key)))
25
- end
26
-
27
- def cost_by_tag(key, limit: nil)
28
- relation = group_by_tag(key).order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
29
- relation = relation.limit(limit) if limit
30
-
31
- costs = relation.sum(:total_cost).each_with_object(Hash.new(0.0)) do |(tag_value, cost), grouped|
32
- grouped[tag_value_label(tag_value)] += cost.to_f
33
- end
34
- costs.sort_by { |_label, cost| -cost }.to_h
35
- end
36
-
37
- def average_latency_ms
38
- return nil unless latency_column?
39
-
40
- average(:latency_ms)&.to_f
41
- end
42
-
43
- def latency_by_model
44
- return {} unless latency_column?
45
-
46
- group(:model).average(:latency_ms).transform_values(&:to_f)
47
- end
48
-
49
- def latency_by_provider
50
- return {} unless latency_column?
51
-
52
- group(:provider).average(:latency_ms).transform_values(&:to_f)
53
- end
54
-
55
- def tag_value_label(value)
56
- TagSql.value_label(value)
57
- end
58
-
59
- def tag_value_expression(key, table_name: quoted_table_name)
60
- TagSql.value_expression(self, key, table_name: table_name)
61
- end
62
- end
63
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module ParameterHash
5
- class << self
6
- def hash_like?(value)
7
- value.is_a?(Hash) || action_controller_parameters?(value)
8
- end
9
-
10
- def to_hash(value)
11
- return {} if value.nil?
12
- return value.to_unsafe_h if action_controller_parameters?(value)
13
- return value.to_h if value.is_a?(Hash)
14
- return {} unless value.respond_to?(:to_h)
15
-
16
- hash = value.to_h
17
- hash.is_a?(Hash) ? hash : {}
18
- rescue ArgumentError, TypeError
19
- {}
20
- end
21
-
22
- def with_indifferent_access(value)
23
- to_hash(value).with_indifferent_access
24
- end
25
-
26
- private
27
-
28
- def action_controller_parameters?(value)
29
- defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
30
- end
31
- end
32
- end
33
- end
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- ParsedUsage = Data.define(
5
- :provider,
6
- :model,
7
- :input_tokens,
8
- :output_tokens,
9
- :total_tokens,
10
- :cache_read_input_tokens,
11
- :cache_write_input_tokens,
12
- :hidden_output_tokens,
13
- :stream,
14
- :usage_source,
15
- :provider_response_id
16
- )
17
-
18
- class ParsedUsage
19
- UNKNOWN_MODEL = "unknown"
20
- TRACKING_KEYS = %i[
21
- provider
22
- model
23
- input_tokens
24
- output_tokens
25
- total_tokens
26
- stream
27
- usage_source
28
- provider_response_id
29
- ].freeze
30
-
31
- def self.build(**attributes)
32
- new(
33
- provider: attributes.fetch(:provider),
34
- model: normalize_model(attributes.fetch(:model)),
35
- input_tokens: attributes.fetch(:input_tokens).to_i,
36
- output_tokens: attributes.fetch(:output_tokens).to_i,
37
- total_tokens: attributes.fetch(:total_tokens, usage_breakdown(attributes).total_tokens).to_i,
38
- cache_read_input_tokens: attributes[:cache_read_input_tokens],
39
- cache_write_input_tokens: attributes[:cache_write_input_tokens],
40
- hidden_output_tokens: attributes[:hidden_output_tokens],
41
- stream: attributes[:stream] || false,
42
- usage_source: attributes[:usage_source],
43
- provider_response_id: attributes[:provider_response_id]
44
- )
45
- end
46
-
47
- def metadata
48
- to_h.except(*TRACKING_KEYS)
49
- end
50
-
51
- def to_h
52
- super.compact
53
- end
54
-
55
- def self.usage_breakdown(attributes)
56
- UsageBreakdown.build(
57
- input_tokens: attributes.fetch(:input_tokens),
58
- output_tokens: attributes.fetch(:output_tokens),
59
- cache_read_input_tokens: attributes[:cache_read_input_tokens],
60
- cache_write_input_tokens: attributes[:cache_write_input_tokens],
61
- hidden_output_tokens: attributes[:hidden_output_tokens]
62
- )
63
- end
64
- private_class_method :usage_breakdown
65
-
66
- def self.normalize_model(value)
67
- model = value.to_s.strip
68
- model.empty? ? UNKNOWN_MODEL : model
69
- end
70
- private_class_method :normalize_model
71
- end
72
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "monitor"
4
-
5
- module LlmCostTracker
6
- module Parsers
7
- class Registry
8
- MUTEX = Monitor.new
9
-
10
- class << self
11
- def parsers
12
- @parsers || MUTEX.synchronize { @parsers ||= default_parsers.freeze }
13
- end
14
-
15
- def register(parser)
16
- parser = coerce_parser(parser)
17
-
18
- MUTEX.synchronize do
19
- current = @parsers || default_parsers.freeze
20
- @parsers = ([parser] + current).freeze
21
- end
22
-
23
- parser
24
- end
25
-
26
- def find_for(url)
27
- parsers.find { |parser| parser.match?(url) }
28
- end
29
-
30
- def find_for_provider(provider)
31
- provider_name = provider.to_s.downcase
32
- parsers.find { |parser| provider_names_for(parser).include?(provider_name) }
33
- end
34
-
35
- def reset!
36
- MUTEX.synchronize { @parsers = nil }
37
- end
38
-
39
- private
40
-
41
- def coerce_parser(parser)
42
- return parser.new if parser.is_a?(Class) && parser <= Base
43
- return parser if parser.is_a?(Base)
44
-
45
- raise ArgumentError, "parser must be a LlmCostTracker::Parsers::Base instance or class"
46
- end
47
-
48
- def provider_names_for(parser)
49
- Array(parser.provider_names).map { |name| name.to_s.downcase }
50
- end
51
-
52
- def default_parsers
53
- [Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
54
- end
55
- end
56
- end
57
- end
58
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "active_record_adapter"
4
-
5
- module LlmCostTracker
6
- module PeriodGrouping
7
- PERIOD_FORMATS = {
8
- day: {
9
- postgres: "YYYY-MM-DD",
10
- mysql: "%Y-%m-%d",
11
- sqlite: "%Y-%m-%d"
12
- },
13
- month: {
14
- postgres: "YYYY-MM",
15
- mysql: "%Y-%m",
16
- sqlite: "%Y-%m"
17
- }
18
- }.freeze
19
-
20
- private_constant :PERIOD_FORMATS
21
-
22
- def group_by_period(period, column: :tracked_at)
23
- group(Arel.sql(period_group_expression(period, column: column)))
24
- end
25
-
26
- def daily_costs(days: 30)
27
- where(tracked_at: days.days.ago..)
28
- .group_by_period(:day)
29
- .sum(:total_cost)
30
- end
31
-
32
- private
33
-
34
- def period_group_expression(period, column:)
35
- period = validated_period(period)
36
- column = period_column_expression(column)
37
- formats = PERIOD_FORMATS.fetch(period)
38
-
39
- if ActiveRecordAdapter.postgresql?(connection)
40
- postgres_period_expression(period, column, formats)
41
- elsif ActiveRecordAdapter.mysql?(connection)
42
- "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
43
- else
44
- "strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"
45
- end
46
- end
47
-
48
- def postgres_period_expression(period, column, formats)
49
- "TO_CHAR(" \
50
- "DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
51
- "#{connection.quote(formats.fetch(:postgres))}" \
52
- ")"
53
- end
54
-
55
- def validated_period(period)
56
- normalized_period = period.respond_to?(:to_sym) ? period.to_sym : nil
57
- return normalized_period if PERIOD_FORMATS.key?(normalized_period)
58
-
59
- raise ArgumentError, "invalid period: #{period.inspect}"
60
- end
61
-
62
- def period_column_expression(column)
63
- column = column.to_s
64
- return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
65
-
66
- raise ArgumentError, "invalid period column: #{column.inspect}"
67
- end
68
- end
69
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_record"
4
-
5
- module LlmCostTracker
6
- class PeriodTotal < ActiveRecord::Base
7
- self.table_name = "llm_cost_tracker_period_totals"
8
- end
9
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "date"
4
-
5
- module LlmCostTracker
6
- module PriceFreshness
7
- STALE_AFTER_DAYS = 30
8
-
9
- class << self
10
- def call(metadata, today: Date.today)
11
- updated_at = metadata["updated_at"] || metadata[:updated_at]
12
- return missing unless updated_at
13
-
14
- date = Date.iso8601(updated_at.to_s)
15
- age_days = (today - date).to_i
16
- return stale(updated_at) if age_days > STALE_AFTER_DAYS
17
-
18
- [:ok, "updated_at=#{updated_at}"]
19
- rescue Date::Error
20
- [:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; run bin/rails llm_cost_tracker:prices:refresh"]
21
- end
22
-
23
- private
24
-
25
- def missing
26
- [:warn, "metadata.updated_at missing; run bin/rails llm_cost_tracker:prices:refresh"]
27
- end
28
-
29
- def stale(updated_at)
30
- [
31
- :warn,
32
- "updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; " \
33
- "run bin/rails llm_cost_tracker:prices:refresh"
34
- ]
35
- end
36
- end
37
- end
38
- end