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,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "stream_tracker"
4
+ require_relative "../capture/stream_collector"
5
+ require_relative "../capture/stream_tracker"
5
6
 
6
7
  module LlmCostTracker
7
8
  module Integrations
@@ -9,11 +10,17 @@ module LlmCostTracker
9
10
  extend Base
10
11
 
11
12
  class << self
12
- def integration_name = :anthropic
13
+ def integration_name
14
+ :anthropic
15
+ end
13
16
 
14
- def minimum_version = "1.36.0"
17
+ def minimum_version
18
+ "1.36.0"
19
+ end
15
20
 
16
- def version_constant = "Anthropic::VERSION"
21
+ def version_constant
22
+ "Anthropic::VERSION"
23
+ end
17
24
 
18
25
  def patch_targets
19
26
  [
@@ -31,56 +38,67 @@ module LlmCostTracker
31
38
  return unless active?
32
39
 
33
40
  record_safely do
34
- usage = ObjectReader.first(message, :usage)
41
+ usage = object_value(message, :usage)
35
42
  next unless usage
36
43
 
37
- input_tokens = ObjectReader.first(usage, :input_tokens)
38
- output_tokens = ObjectReader.first(usage, :output_tokens)
44
+ input_tokens = object_value(usage, :input_tokens)
45
+ output_tokens = object_value(usage, :output_tokens)
39
46
  next if input_tokens.nil? && output_tokens.nil?
40
47
 
41
48
  LlmCostTracker::Tracker.record(
42
- provider: "anthropic",
43
- model: ObjectReader.first(message, :model) || request[:model],
44
- input_tokens: ObjectReader.integer(input_tokens),
45
- output_tokens: ObjectReader.integer(output_tokens),
46
- latency_ms: latency_ms,
47
- usage_source: :sdk_response,
48
- provider_response_id: ObjectReader.first(message, :id),
49
- metadata: usage_metadata(usage)
49
+ capture: UsageCapture.build(
50
+ provider: "anthropic",
51
+ model: object_value(message, :model) || request[:model],
52
+ pricing_mode: object_value(usage, :service_tier) || object_value(message, :service_tier) ||
53
+ request[:service_tier],
54
+ token_usage: token_usage(usage, input_tokens, output_tokens),
55
+ usage_source: :sdk_response,
56
+ provider_response_id: object_value(message, :id)
57
+ ),
58
+ latency_ms: latency_ms
50
59
  )
51
60
  end
52
61
  end
53
62
 
54
- def usage_metadata(usage)
55
- {
56
- cache_read_input_tokens: ObjectReader.integer(ObjectReader.first(usage, :cache_read_input_tokens)),
57
- cache_write_input_tokens: ObjectReader.integer(ObjectReader.first(usage, :cache_creation_input_tokens)),
58
- hidden_output_tokens: hidden_output_tokens(usage)
59
- }
60
- end
61
-
62
- def hidden_output_tokens(usage)
63
- ObjectReader.integer(
64
- ObjectReader.first(usage, :thinking_tokens, :thinking_output_tokens) ||
65
- ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens)
63
+ def token_usage(usage, input_tokens, output_tokens)
64
+ cache_write_1h = object_dig(usage, :cache_creation, :ephemeral_1h_input_tokens).to_i
65
+ cache_write_5m = object_dig(usage, :cache_creation, :ephemeral_5m_input_tokens)
66
+ cache_write = if cache_write_5m.nil?
67
+ total_cache_write = object_value(usage, :cache_creation_input_tokens)
68
+ [total_cache_write.to_i - cache_write_1h, 0].max
69
+ else
70
+ cache_write_5m.to_i
71
+ end
72
+ hidden_output = (
73
+ object_value(usage, :thinking_tokens, :thinking_output_tokens) ||
74
+ object_dig(usage, :output_tokens_details, :reasoning_tokens)
75
+ ).to_i
76
+
77
+ TokenUsage.build(
78
+ input_tokens: input_tokens.to_i,
79
+ output_tokens: output_tokens.to_i,
80
+ cache_read_input_tokens: object_value(usage, :cache_read_input_tokens).to_i,
81
+ cache_write_input_tokens: cache_write,
82
+ cache_write_1h_input_tokens: cache_write_1h,
83
+ hidden_output_tokens: hidden_output
66
84
  )
67
85
  end
68
86
 
69
87
  def track_stream(stream, collector:)
70
88
  return stream unless active?
71
89
 
72
- StreamTracker.wrap(
90
+ LlmCostTracker::Capture::StreamTracker.new(
73
91
  stream,
74
- collector: collector,
75
- active: -> { active? },
76
- finish: ->(errored:) { finish_stream(collector, errored: errored) }
77
- )
92
+ collector,
93
+ -> { active? },
94
+ ->(errored:) { finish_stream(collector, errored: errored) }
95
+ ).wrap
78
96
  end
79
97
 
80
98
  def stream_collector(request)
81
- LlmCostTracker::StreamCollector.new(
99
+ LlmCostTracker::Capture::StreamCollector.new(
82
100
  provider: "anthropic",
83
- model: request[:model] || request["model"]
101
+ model: request[:model]
84
102
  )
85
103
  end
86
104
 
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+ require "active_support/core_ext/object/try"
5
+ require "active_support/core_ext/string/inflections"
6
+
3
7
  require_relative "../logging"
4
- require_relative "object_reader"
5
8
 
6
9
  module LlmCostTracker
7
10
  module Integrations
8
11
  module Base
9
- PatchTarget = Data.define(:constant_name, :patch, :method_names, :optional)
10
12
  Result = Data.define(:name, :status, :message)
11
13
 
12
14
  def active?
@@ -16,20 +18,22 @@ module LlmCostTracker
16
18
  def install
17
19
  validate_contract!
18
20
  patch_targets.each do |target|
19
- target_class = constant(target.constant_name)
20
- install_patch(target_class, target.patch) if target_class
21
+ target_class = target.fetch(:constant_name).to_s.safe_constantize
22
+ install_patch(target_class, target.fetch(:patch)) if target_class
21
23
  end
22
24
  end
23
25
 
24
26
  def status
25
27
  name = integration_name
26
- problems = contract_problems
28
+ problems = version_problems + target_problems
27
29
  if problems.any?
28
30
  return Result.new(name, :warn, "#{name} integration cannot be installed: #{problems.join('; ')}")
29
31
  end
30
32
 
31
- required_targets = patch_targets.reject(&:optional)
32
- installed = required_targets.count { |target| patch_installed?(constant(target.constant_name), target.patch) }
33
+ required_targets = patch_targets.reject { |target| target.fetch(:optional) }
34
+ installed = required_targets.count do |target|
35
+ target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
36
+ end
33
37
  return Result.new(name, :ok, "#{name} integration installed") if installed == required_targets.count
34
38
 
35
39
  Result.new(name, :warn, "#{name} integration is enabled but not installed")
@@ -53,14 +57,31 @@ module LlmCostTracker
53
57
 
54
58
  def request_params(args, kwargs)
55
59
  params = args.first.is_a?(Hash) ? args.first : {}
56
- params.merge(kwargs)
60
+ params.merge(kwargs).with_indifferent_access
61
+ end
62
+
63
+ def object_value(object, *keys)
64
+ keys.each do |key|
65
+ value = read_object_value(object, key)
66
+ return value unless value.nil?
67
+ end
68
+ nil
57
69
  end
58
70
 
59
- def constant(path)
60
- path.to_s.split("::").reduce(Object) do |scope, const_name|
61
- return nil unless scope.const_defined?(const_name, false)
71
+ def object_dig(object, *path)
72
+ if object.respond_to?(:dig)
73
+ begin
74
+ value = object.dig(*path)
75
+ return value unless value.nil?
76
+ rescue NameError, TypeError
77
+ nil
78
+ end
79
+ end
80
+
81
+ path.reduce(object) do |current, key|
82
+ return nil if current.nil?
62
83
 
63
- scope.const_get(const_name, false)
84
+ read_object_value(current, key)
64
85
  end
65
86
  end
66
87
 
@@ -71,41 +92,62 @@ module LlmCostTracker
71
92
  def patch_targets = []
72
93
 
73
94
  def patch_target(constant_name, with:, methods:, optional: false)
74
- PatchTarget.new(constant_name, with, Array(methods), optional)
95
+ {
96
+ constant_name: constant_name,
97
+ patch: with,
98
+ method_names: Array(methods),
99
+ optional: optional
100
+ }
75
101
  end
76
102
 
103
+ module_function :object_value, :object_dig
104
+
77
105
  private
78
106
 
107
+ def read_object_value(object, key)
108
+ return nil if object.nil?
109
+ return object[key] if object.try(:key?, key)
110
+
111
+ string_key = key.to_s
112
+ return object[string_key] if object.try(:key?, string_key)
113
+
114
+ value = object.try(key)
115
+ return value unless value.nil?
116
+
117
+ indexed_object_value(object, key)
118
+ end
119
+
120
+ def indexed_object_value(object, key)
121
+ object.try(:[], key)
122
+ rescue IndexError, NameError, TypeError
123
+ nil
124
+ end
125
+
126
+ module_function :read_object_value, :indexed_object_value
127
+ private_class_method :read_object_value, :indexed_object_value
128
+
79
129
  def validate_contract!
80
- problems = contract_problems
130
+ problems = version_problems + target_problems
81
131
  return if problems.empty?
82
132
 
83
133
  raise Error, "#{integration_name} integration cannot be installed: #{problems.join('; ')}"
84
134
  end
85
135
 
86
- def contract_problems
87
- version_problems + target_problems
88
- end
89
-
90
136
  def version_problems
91
137
  return [] unless minimum_version
92
138
 
93
139
  name = integration_name.to_s
94
- version = installed_version
140
+ version = Gem.loaded_specs[integration_name.to_s]&.version || constant_version
95
141
  return ["#{name} >= #{minimum_version} is required, but #{name} is not loaded"] unless version
96
142
  return [] if version >= Gem::Version.new(minimum_version)
97
143
 
98
144
  ["#{name} >= #{minimum_version} is required, detected #{version}"]
99
145
  end
100
146
 
101
- def installed_version
102
- Gem.loaded_specs[integration_name.to_s]&.version || constant_version
103
- end
104
-
105
147
  def constant_version
106
148
  return nil unless version_constant
107
149
 
108
- value = constant(version_constant)
150
+ value = version_constant.to_s.safe_constantize
109
151
  value ? Gem::Version.new(value.to_s) : nil
110
152
  rescue ArgumentError
111
153
  nil
@@ -113,31 +155,28 @@ module LlmCostTracker
113
155
 
114
156
  def target_problems
115
157
  patch_targets.flat_map do |target|
116
- target_class = constant(target.constant_name)
117
- next [] if target_class.nil? && target.optional
118
- next ["#{target.constant_name} is not loaded"] unless target_class
158
+ constant_name = target.fetch(:constant_name)
159
+ target_class = constant_name.to_s.safe_constantize
160
+ next [] if target_class.nil? && target.fetch(:optional)
161
+ next ["#{constant_name} is not loaded"] unless target_class
119
162
 
120
163
  missing_methods(target_class, target)
121
164
  end
122
165
  end
123
166
 
124
167
  def missing_methods(target_class, target)
125
- target.method_names.filter_map do |method_name|
168
+ target.fetch(:method_names).filter_map do |method_name|
126
169
  next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
127
170
 
128
- "#{target.constant_name}##{method_name} is not available"
171
+ "#{target.fetch(:constant_name)}##{method_name} is not available"
129
172
  end
130
173
  end
131
174
 
132
175
  def install_patch(target, patch)
133
- return if patch_installed?(target, patch)
176
+ return if target&.ancestors&.include?(patch)
134
177
 
135
178
  target.prepend(patch)
136
179
  end
137
-
138
- def patch_installed?(target, patch)
139
- target&.ancestors&.include?(patch)
140
- end
141
180
  end
142
181
  end
143
182
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "stream_tracker"
4
+ require_relative "../capture/stream_collector"
5
+ require_relative "../capture/stream_tracker"
5
6
 
6
7
  module LlmCostTracker
7
8
  module Integrations
@@ -9,11 +10,17 @@ module LlmCostTracker
9
10
  extend Base
10
11
 
11
12
  class << self
12
- def integration_name = :openai
13
+ def integration_name
14
+ :openai
15
+ end
13
16
 
14
- def minimum_version = "0.59.0"
17
+ def minimum_version
18
+ "0.59.0"
19
+ end
15
20
 
16
- def version_constant = "OpenAI::VERSION"
21
+ def version_constant
22
+ "OpenAI::VERSION"
23
+ end
17
24
 
18
25
  def patch_targets
19
26
  [
@@ -34,67 +41,66 @@ module LlmCostTracker
34
41
  return unless active?
35
42
 
36
43
  record_safely do
37
- usage = ObjectReader.first(response, :usage)
44
+ usage = object_value(response, :usage)
38
45
  next unless usage
39
46
 
40
- input_tokens = ObjectReader.first(usage, :input_tokens, :prompt_tokens)
41
- output_tokens = ObjectReader.first(usage, :output_tokens, :completion_tokens)
47
+ input_tokens = object_value(usage, :input_tokens, :prompt_tokens)
48
+ output_tokens = object_value(usage, :output_tokens, :completion_tokens)
42
49
  next if input_tokens.nil? && output_tokens.nil?
43
50
 
44
- metadata = usage_metadata(usage)
51
+ cache_read = cache_read_input_tokens(usage)
45
52
  LlmCostTracker::Tracker.record(
46
- provider: "openai",
47
- model: ObjectReader.first(response, :model) || request[:model],
48
- input_tokens: regular_input_tokens(input_tokens, metadata[:cache_read_input_tokens]),
49
- output_tokens: ObjectReader.integer(output_tokens),
50
- latency_ms: latency_ms,
51
- usage_source: :sdk_response,
52
- provider_response_id: ObjectReader.first(response, :id),
53
- metadata: metadata
53
+ capture: UsageCapture.build(
54
+ provider: "openai",
55
+ model: object_value(response, :model) || request[:model],
56
+ pricing_mode: object_value(response, :service_tier) || request[:service_tier],
57
+ token_usage: TokenUsage.build(
58
+ input_tokens: regular_input_tokens(input_tokens, cache_read),
59
+ output_tokens: output_tokens.to_i,
60
+ cache_read_input_tokens: cache_read,
61
+ hidden_output_tokens: hidden_output_tokens(usage)
62
+ ),
63
+ usage_source: :sdk_response,
64
+ provider_response_id: object_value(response, :id)
65
+ ),
66
+ latency_ms: latency_ms
54
67
  )
55
68
  end
56
69
  end
57
70
 
58
- def usage_metadata(usage)
59
- {
60
- cache_read_input_tokens: cache_read_input_tokens(usage),
61
- hidden_output_tokens: hidden_output_tokens(usage)
62
- }
63
- end
64
-
65
71
  def cache_read_input_tokens(usage)
66
- ObjectReader.integer(
67
- ObjectReader.nested(usage, :input_tokens_details, :cached_tokens) ||
68
- ObjectReader.nested(usage, :prompt_tokens_details, :cached_tokens)
69
- )
72
+ (
73
+ object_dig(usage, :input_tokens_details, :cached_tokens) ||
74
+ object_dig(usage, :prompt_tokens_details, :cached_tokens)
75
+ ).to_i
70
76
  end
71
77
 
72
78
  def hidden_output_tokens(usage)
73
- ObjectReader.integer(
74
- ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens) ||
75
- ObjectReader.nested(usage, :completion_tokens_details, :reasoning_tokens)
76
- )
79
+ (
80
+ object_dig(usage, :output_tokens_details, :reasoning_tokens) ||
81
+ object_dig(usage, :completion_tokens_details, :reasoning_tokens)
82
+ ).to_i
77
83
  end
78
84
 
79
85
  def regular_input_tokens(input_tokens, cache_read)
80
- [ObjectReader.integer(input_tokens) - cache_read.to_i, 0].max
86
+ [input_tokens.to_i - cache_read.to_i, 0].max
81
87
  end
82
88
 
83
89
  def track_stream(stream, collector:)
84
90
  return stream unless active?
85
91
 
86
- StreamTracker.wrap(
92
+ LlmCostTracker::Capture::StreamTracker.new(
87
93
  stream,
88
- collector: collector,
89
- active: -> { active? },
90
- finish: ->(errored:) { finish_stream(collector, errored: errored) }
91
- )
94
+ collector,
95
+ -> { active? },
96
+ ->(errored:) { finish_stream(collector, errored: errored) }
97
+ ).wrap
92
98
  end
93
99
 
94
100
  def stream_collector(request)
95
- LlmCostTracker::StreamCollector.new(
101
+ LlmCostTracker::Capture::StreamCollector.new(
96
102
  provider: "openai",
97
- model: request[:model] || request["model"]
103
+ model: request[:model]
98
104
  )
99
105
  end
100
106
 
@@ -8,11 +8,17 @@ module LlmCostTracker
8
8
  extend Base
9
9
 
10
10
  class << self
11
- def integration_name = :ruby_llm
11
+ def integration_name
12
+ :ruby_llm
13
+ end
12
14
 
13
- def minimum_version = "1.14.1"
15
+ def minimum_version
16
+ "1.14.1"
17
+ end
14
18
 
15
- def version_constant = "RubyLLM::VERSION"
19
+ def version_constant
20
+ "RubyLLM::VERSION"
21
+ end
16
22
 
17
23
  def patch_targets
18
24
  [
@@ -63,59 +69,63 @@ module LlmCostTracker
63
69
  return unless active?
64
70
 
65
71
  record_safely do
66
- input_tokens = ObjectReader.first(response, :input_tokens)
67
- output_tokens = ObjectReader.first(response, :output_tokens) if output_tokens.nil?
72
+ input_tokens = object_value(response, :input_tokens)
73
+ output_tokens = object_value(response, :output_tokens) if output_tokens.nil?
68
74
  next if input_tokens.nil? && output_tokens.nil?
69
75
 
70
- cache_read = ObjectReader.integer(ObjectReader.first(response, :cached_tokens))
76
+ cache_read = object_value(response, :cached_tokens).to_i
77
+ hidden_output = object_value(response, :thinking_tokens, :reasoning_tokens).to_i
71
78
 
72
79
  LlmCostTracker::Tracker.record(
73
- provider: provider,
74
- model: model,
75
- input_tokens: regular_input_tokens(input_tokens, cache_read),
76
- output_tokens: ObjectReader.integer(output_tokens),
77
- latency_ms: latency_ms,
78
- stream: stream,
79
- usage_source: :ruby_llm,
80
- provider_response_id: provider_response_id(response),
81
- metadata: usage_metadata(response, cache_read)
80
+ capture: UsageCapture.build(
81
+ provider: provider,
82
+ model: model,
83
+ pricing_mode: pricing_mode(response),
84
+ token_usage: TokenUsage.build(
85
+ input_tokens: regular_input_tokens(input_tokens, cache_read),
86
+ output_tokens: output_tokens.to_i,
87
+ cache_read_input_tokens: cache_read,
88
+ cache_write_input_tokens: object_value(response, :cache_creation_tokens).to_i,
89
+ hidden_output_tokens: hidden_output
90
+ ),
91
+ stream: stream,
92
+ usage_source: :ruby_llm,
93
+ provider_response_id: provider_response_id(response)
94
+ ),
95
+ latency_ms: latency_ms
82
96
  )
83
97
  end
84
98
  end
85
99
 
86
- def usage_metadata(response, cache_read)
87
- {
88
- cache_read_input_tokens: cache_read,
89
- cache_write_input_tokens: ObjectReader.integer(ObjectReader.first(response, :cache_creation_tokens)),
90
- hidden_output_tokens: ObjectReader.integer(
91
- ObjectReader.first(response, :thinking_tokens, :reasoning_tokens)
92
- )
93
- }
94
- end
95
-
96
100
  def regular_input_tokens(input_tokens, cache_read)
97
- [ObjectReader.integer(input_tokens) - cache_read.to_i, 0].max
101
+ [input_tokens.to_i - cache_read.to_i, 0].max
98
102
  end
99
103
 
100
104
  def provider_slug(provider)
101
- ObjectReader.first(provider, :slug).to_s
105
+ object_value(provider, :slug).to_s
102
106
  end
103
107
 
104
108
  def model_id(object)
105
109
  return nil if object.nil?
106
110
 
107
- value = ObjectReader.first(object, :id, :model_id, :model)
111
+ value = object_value(object, :id, :model_id, :model)
108
112
  value ||= object if object.is_a?(String) || object.is_a?(Symbol)
109
113
  value&.to_s
110
114
  end
111
115
 
112
116
  def response_model_id(object)
113
- value = ObjectReader.first(object, :model_id, :model)
117
+ value = object_value(object, :model_id, :model)
114
118
  value&.to_s
115
119
  end
116
120
 
117
121
  def provider_response_id(response)
118
- ObjectReader.first(response, :id, :provider_response_id) || ObjectReader.nested(response, :raw, :id)
122
+ object_value(response, :id, :provider_response_id) || object_dig(response, :raw, :id)
123
+ end
124
+
125
+ def pricing_mode(response)
126
+ object_value(response, :pricing_mode, :service_tier) ||
127
+ object_dig(response, :raw, :pricing_mode) ||
128
+ object_dig(response, :raw, :service_tier)
119
129
  end
120
130
  end
121
131
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "integrations/openai"
5
+ require_relative "integrations/anthropic"
6
+ require_relative "integrations/ruby_llm"
7
+
8
+ module LlmCostTracker
9
+ module Integrations
10
+ AVAILABLE = {
11
+ openai: Openai,
12
+ anthropic: Anthropic,
13
+ ruby_llm: RubyLlm
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def install!(names = LlmCostTracker.configuration.instrumented_integrations)
19
+ normalize(names).each { |name| fetch(name).install }
20
+ end
21
+
22
+ def checks(names = LlmCostTracker.configuration.instrumented_integrations)
23
+ return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
24
+
25
+ normalize(names).map { |name| fetch(name).status }
26
+ end
27
+
28
+ def normalize(names)
29
+ Array(names).flatten.map(&:to_sym).uniq
30
+ end
31
+
32
+ def fetch(name)
33
+ AVAILABLE.fetch(name.to_sym) do
34
+ message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
35
+ raise LlmCostTracker::Error, message
36
+ end
37
+ end
38
+
39
+ def names
40
+ AVAILABLE.keys
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../period"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Period
8
+ class Totals
9
+ def self.call(periods, time:)
10
+ new(periods, time: time).totals
11
+ end
12
+
13
+ def initialize(periods, time:)
14
+ @periods = Period.valid_keys(periods)
15
+ @time = time
16
+ end
17
+
18
+ def totals
19
+ return {} if periods.empty?
20
+
21
+ snapshot_totals
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :periods, :time
27
+
28
+ def snapshot_totals
29
+ values = periods.to_h { |period| [period, 0.0] }
30
+ sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
31
+ LlmCostTracker::Ledger::Call.find_by_sql(sql).each do |row|
32
+ values[row.period_key.to_sym] = row.total_cost.to_f
33
+ end
34
+ values
35
+ end
36
+
37
+ def snapshot_select(period)
38
+ start = Period.range_start(period, time)
39
+ "SELECT #{connection.quote(period.to_s)} AS period_key, " \
40
+ "(#{rollup_total_sql(period)}) + (#{pending_total_sql(start)}) AS total_cost"
41
+ end
42
+
43
+ def rollup_total_sql(period)
44
+ table = connection.quote_table_name("llm_cost_tracker_period_totals")
45
+ "COALESCE((SELECT total_cost FROM #{table} " \
46
+ "WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
47
+ "AND period_start = #{connection.quote(Period.bucket(period, time))} LIMIT 1), 0)"
48
+ end
49
+
50
+ def pending_total_sql(start)
51
+ table = connection.quote_table_name(Ingestion::Event.table_name)
52
+ total_cost = connection.quote_column_name("total_cost")
53
+ tracked_at = connection.quote_column_name("tracked_at")
54
+ attempts = connection.quote_column_name("attempts")
55
+ "COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
56
+ "WHERE #{attempts} < #{Ingestion::Event::MAX_ATTEMPTS} " \
57
+ "AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
58
+ end
59
+
60
+ def connection
61
+ LlmCostTracker::Ledger::Call.connection
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end