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,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,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