llm_cost_tracker 0.10.0 → 0.12.0

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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -3,7 +3,7 @@
3
3
  require "active_support/core_ext/hash/indifferent_access"
4
4
  require "active_support/core_ext/string/inflections"
5
5
 
6
- require_relative "../doctor/check"
6
+ require_relative "../check"
7
7
  require_relative "../logging"
8
8
  require_relative "../timing"
9
9
  require_relative "../capture/stream_collector"
@@ -12,7 +12,14 @@ require_relative "../capture/stream_tracker"
12
12
  module LlmCostTracker
13
13
  module Integrations
14
14
  module Base
15
- Result = LlmCostTracker::Doctor::Check
15
+ def integration_name
16
+ @integration_name ||= name.demodulize.underscore.to_sym
17
+ end
18
+
19
+ def provider(value = nil)
20
+ @provider = value.to_s if value
21
+ @provider ||= integration_name.to_s
22
+ end
16
23
 
17
24
  def active?
18
25
  LlmCostTracker.configuration.instrumented?(integration_name)
@@ -30,22 +37,22 @@ module LlmCostTracker
30
37
  name = integration_name.to_s
31
38
  problems = version_problems + target_problems
32
39
  if problems.any?
33
- return Result.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
40
+ return Check.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
34
41
  end
35
42
 
36
43
  installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
37
44
  target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
38
45
  end
39
- return Result.new(:ok, name, "#{name} integration installed") if installed
46
+ return Check.new(:ok, name, "#{name} integration installed") if installed
40
47
 
41
- Result.new(:warn, name, "#{name} integration is enabled but not installed")
48
+ Check.new(:warn, name, "#{name} integration is enabled but not installed")
42
49
  end
43
50
 
44
- def enforce_budget!(request:)
51
+ def enforce_budget!(request:, provider: self.provider)
45
52
  return unless active?
46
53
 
47
- LlmCostTracker::Tracker.enforce_budget!(
48
- provider: integration_name.to_s,
54
+ LlmCostTracker::Budget.enforce!(
55
+ provider: provider,
49
56
  model: request[:model],
50
57
  request: request
51
58
  )
@@ -71,10 +78,21 @@ module LlmCostTracker
71
78
  kwargs.to_h.with_indifferent_access
72
79
  end
73
80
 
74
- def normalize_sdk_args(args, kwargs)
75
- return args if args.any? || kwargs.empty?
81
+ def wrap_blocking(args, kwargs, record:, provider: self.provider)
82
+ request = request_params(args, kwargs)
83
+ enforce_budget!(request: request, provider: provider)
84
+ started_at = LlmCostTracker::Timing.now_monotonic
85
+ response = yield
86
+ record.call(response, request, LlmCostTracker::Timing.elapsed_ms(started_at))
87
+ response
88
+ end
76
89
 
77
- [kwargs]
90
+ def wrap_stream(args, kwargs, collector:, provider: self.provider)
91
+ request = request_params(args, kwargs)
92
+ enforce_budget!(request: request, provider: provider)
93
+ built = collector.call(request)
94
+ stream = yield(built)
95
+ track_stream(stream, collector: built)
78
96
  end
79
97
 
80
98
  def track_stream(stream, collector:)
@@ -88,9 +106,9 @@ module LlmCostTracker
88
106
  ).wrap
89
107
  end
90
108
 
91
- def stream_collector(request)
109
+ def stream_collector(request, provider: self.provider)
92
110
  LlmCostTracker::Capture::StreamCollector.new(
93
- provider: integration_name.to_s,
111
+ provider: provider,
94
112
  model: request[:model],
95
113
  pricing_mode: stream_pricing_mode(request),
96
114
  request: request
@@ -101,56 +119,29 @@ module LlmCostTracker
101
119
  nil
102
120
  end
103
121
 
104
- def object_value(object, *keys)
105
- keys.each do |key|
106
- value = read_object_value(object, key)
107
- return value unless value.nil?
108
- end
109
- nil
122
+ def minimum_version(value = nil)
123
+ @minimum_version = value if value
124
+ @minimum_version
110
125
  end
111
126
 
112
- def object_dig(object, *path)
113
- path.reduce(object) do |current, key|
114
- return nil if current.nil?
115
-
116
- read_object_value(current, key)
117
- end
127
+ def gem_version
128
+ Gem.loaded_specs[integration_name.to_s]&.version
118
129
  end
119
130
 
120
- def minimum_version = nil
121
-
122
- def version_constant = nil
123
-
124
131
  def patch_targets = []
125
132
 
126
- def patch_target(constant_name, with:, methods:, optional: false, skip_when_methods_missing: false)
133
+ def patch_target(constant_name, with:, optional: false, skip_when_methods_missing: false)
127
134
  {
128
135
  constant_name: constant_name,
129
136
  patch: with,
130
- method_names: Array(methods),
137
+ method_names: with.instance_methods,
131
138
  optional: optional,
132
139
  skip_when_methods_missing: skip_when_methods_missing
133
140
  }
134
141
  end
135
142
 
136
- module_function :object_value, :object_dig
137
-
138
143
  private
139
144
 
140
- def read_object_value(object, key)
141
- return nil if object.nil?
142
-
143
- if object.is_a?(Hash)
144
- return object[key] if object.key?(key)
145
- return object[key.name] if key.is_a?(Symbol) && object.key?(key.name)
146
- end
147
-
148
- object.public_send(key) if object.respond_to?(key)
149
- end
150
-
151
- module_function :read_object_value
152
- private_class_method :read_object_value
153
-
154
145
  def validate_contract!
155
146
  problems = version_problems + target_problems
156
147
  return if problems.empty?
@@ -162,22 +153,13 @@ module LlmCostTracker
162
153
  return [] unless minimum_version
163
154
 
164
155
  name = integration_name.to_s
165
- version = Gem.loaded_specs[integration_name.to_s]&.version || constant_version
156
+ version = gem_version
166
157
  return ["#{name} >= #{minimum_version} is required, but #{name} is not loaded"] unless version
167
158
  return [] if version >= Gem::Version.new(minimum_version)
168
159
 
169
160
  ["#{name} >= #{minimum_version} is required, detected #{version}"]
170
161
  end
171
162
 
172
- def constant_version
173
- return nil unless version_constant
174
-
175
- value = version_constant.to_s.safe_constantize
176
- value ? Gem::Version.new(value.to_s) : nil
177
- rescue ArgumentError
178
- nil
179
- end
180
-
181
163
  def target_problems
182
164
  patch_targets.flat_map do |target|
183
165
  constant_name = target.fetch(:constant_name)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Integrations
7
+ module Openai
8
+ module BatchCapture
9
+ DEDUP_LIMIT = 1024
10
+ MUTEX = Mutex.new
11
+ private_constant :DEDUP_LIMIT, :MUTEX
12
+
13
+ class << self
14
+ def maybe_capture(batch, resource:)
15
+ return unless Openai.active?
16
+ return unless batch.respond_to?(:status) && batch.status.to_s == "completed"
17
+
18
+ output_file_id = batch.respond_to?(:output_file_id) ? batch.output_file_id : nil
19
+ return unless output_file_id
20
+
21
+ batch_id = batch.respond_to?(:id) ? batch.id : nil
22
+ return unless batch_id && claim(batch_id)
23
+
24
+ client = resource.instance_variable_get(:@client)
25
+ host = Openai.client_host_for(resource)
26
+ Openai.record_safely do
27
+ io = client.files.content(output_file_id)
28
+ capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def claim(batch_id)
35
+ MUTEX.synchronize do
36
+ @dedup ||= Set.new
37
+ next false if @dedup.include?(batch_id)
38
+
39
+ @dedup.clear if @dedup.size >= DEDUP_LIMIT
40
+ @dedup.add(batch_id)
41
+ true
42
+ end
43
+ end
44
+
45
+ def capture_jsonl(jsonl, host:)
46
+ jsonl.each_line do |line|
47
+ line = line.strip
48
+ next if line.empty?
49
+
50
+ entry = parse_line(line)
51
+ next unless entry
52
+
53
+ response = entry.dig("response", "body")
54
+ next unless response.is_a?(Hash) && response["usage"]
55
+
56
+ record_result(response, host: host)
57
+ end
58
+ end
59
+
60
+ def parse_line(line)
61
+ JSON.parse(line)
62
+ rescue JSON::ParserError
63
+ nil
64
+ end
65
+
66
+ def record_result(response, host:)
67
+ provider = Openai.provider_for_host(host)
68
+ return if LlmCostTracker::Call.already_recorded?(provider: provider, provider_response_id: response["id"])
69
+
70
+ event = LlmCostTracker::Providers::Openai::ResponseParser.event_from_response(
71
+ response: response,
72
+ request: {},
73
+ provider: provider,
74
+ host: host,
75
+ usage_source: LlmCostTracker::Usage::Source::SDK_BATCH_RESULT,
76
+ pricing_mode: "batch"
77
+ )
78
+ LlmCostTracker::Tracker.record(event: event) if event
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Integrations
5
+ module Openai
6
+ module PatchBuilder
7
+ def self.build(record_method:, methods:)
8
+ Module.new.tap do |mod|
9
+ methods.each { |method_name| define_blocking_method(mod, method_name, record_method) }
10
+ end
11
+ end
12
+
13
+ def self.build_stream(methods:)
14
+ Module.new.tap do |mod|
15
+ methods.each { |method_name| define_stream_method(mod, method_name) }
16
+ end
17
+ end
18
+
19
+ def self.define_blocking_method(mod, method_name, record_method)
20
+ mod.define_method(method_name) do |*args, **kwargs, &block|
21
+ host = LlmCostTracker::Integrations::Openai.client_host_for(self)
22
+ LlmCostTracker::Integrations::Openai.wrap_blocking(
23
+ args,
24
+ kwargs,
25
+ provider: LlmCostTracker::Integrations::Openai.provider_for_host(host),
26
+ record: lambda do |response, request, latency_ms|
27
+ LlmCostTracker::Integrations::Openai.public_send(
28
+ record_method, response, request: request, latency_ms: latency_ms, host: host
29
+ )
30
+ end
31
+ ) { super(*args, **kwargs, &block) }
32
+ end
33
+ end
34
+
35
+ def self.define_stream_method(mod, method_name)
36
+ mod.define_method(method_name) do |*args, **kwargs|
37
+ LlmCostTracker::Integrations::Openai.wrap_stream(
38
+ args, kwargs, **LlmCostTracker::Integrations::Openai.stream_seam(self)
39
+ ) { super(*args, **kwargs) }
40
+ end
41
+ end
42
+ end
43
+
44
+ module ResponsesPatch
45
+ include PatchBuilder.build(record_method: :record_response, methods: %i[create])
46
+ include PatchBuilder.build_stream(methods: %i[stream stream_raw])
47
+
48
+ def retrieve_streaming(response_id, *args, **kwargs)
49
+ LlmCostTracker::Integrations::Openai.wrap_stream(
50
+ args, kwargs, **LlmCostTracker::Integrations::Openai.stream_seam(self)
51
+ ) do |collector|
52
+ collector.provider_response_id = response_id
53
+ super(response_id, *args, **kwargs)
54
+ end
55
+ end
56
+ end
57
+
58
+ module ChatCompletionsPatch
59
+ include PatchBuilder.build(record_method: :record_response, methods: %i[create])
60
+ include PatchBuilder.build_stream(methods: %i[stream stream_raw])
61
+ end
62
+
63
+ EmbeddingsPatch = PatchBuilder.build(record_method: :record_response, methods: %i[create])
64
+ ImagesPatch = PatchBuilder.build(record_method: :record_image, methods: %i[generate edit create_variation])
65
+ TranscriptionsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
66
+ TranslationsPatch = PatchBuilder.build(record_method: :record_transcription, methods: %i[create])
67
+ SpeechPatch = PatchBuilder.build(record_method: :record_speech, methods: %i[create])
68
+ ModerationsPatch = PatchBuilder.build(record_method: :record_moderation, methods: %i[create])
69
+ StreamingImagesPatch = PatchBuilder.build_stream(methods: %i[generate_stream_raw edit_stream_raw])
70
+ StreamingTranscriptionsPatch = PatchBuilder.build_stream(methods: %i[create_streaming])
71
+
72
+ module BatchesPatch
73
+ def retrieve(batch_id, *args, **kwargs)
74
+ batch = super
75
+ LlmCostTracker::Integrations::Openai::BatchCapture.maybe_capture(batch, resource: self)
76
+ batch
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end