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,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,71 +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
- end
71
- 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,67 +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
- },
12
- month: {
13
- postgres: "YYYY-MM",
14
- mysql: "%Y-%m"
15
- }
16
- }.freeze
17
-
18
- private_constant :PERIOD_FORMATS
19
-
20
- def group_by_period(period, column: :tracked_at)
21
- group(Arel.sql(period_group_expression(period, column: column)))
22
- end
23
-
24
- def daily_costs(days: 30)
25
- where(tracked_at: days.days.ago..)
26
- .group_by_period(:day)
27
- .sum(:total_cost)
28
- end
29
-
30
- private
31
-
32
- def period_group_expression(period, column:)
33
- period = validated_period(period)
34
- column = period_column_expression(column)
35
- formats = PERIOD_FORMATS.fetch(period)
36
-
37
- if ActiveRecordAdapter.postgresql?(connection)
38
- postgres_period_expression(period, column, formats)
39
- elsif ActiveRecordAdapter.mysql?(connection)
40
- "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
41
- else
42
- ActiveRecordAdapter.ensure_supported!(connection)
43
- end
44
- end
45
-
46
- def postgres_period_expression(period, column, formats)
47
- "TO_CHAR(" \
48
- "DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
49
- "#{connection.quote(formats.fetch(:postgres))}" \
50
- ")"
51
- end
52
-
53
- def validated_period(period)
54
- normalized_period = period.respond_to?(:to_sym) ? period.to_sym : nil
55
- return normalized_period if PERIOD_FORMATS.key?(normalized_period)
56
-
57
- raise ArgumentError, "invalid period: #{period.inspect}"
58
- end
59
-
60
- def period_column_expression(column)
61
- column = column.to_s
62
- return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
63
-
64
- raise ArgumentError, "invalid period column: #{column.inspect}"
65
- end
66
- end
67
- 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
@@ -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