llm_cost_tracker 0.11.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,37 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../billing/line_item"
5
- require_relative "../providers/anthropic/server_tools"
6
- require_relative "../providers/anthropic/tier_classification"
4
+ require_relative "../providers/anthropic/usage_extractor"
5
+ require_relative "../providers/anthropic/response_parser"
7
6
 
8
7
  module LlmCostTracker
9
8
  module Integrations
10
9
  module Anthropic
11
10
  extend Base
12
11
 
13
- class << self
14
- def integration_name
15
- :anthropic
16
- end
17
-
18
- def minimum_version
19
- "1.36.0"
20
- end
21
-
22
- def version_constant
23
- "Anthropic::VERSION"
24
- end
12
+ minimum_version "1.36.0"
25
13
 
14
+ class << self
26
15
  def patch_targets
27
16
  [
28
- patch_target("Anthropic::Resources::Messages", with: MessagesPatch, methods: %i[create stream stream_raw]),
29
- patch_target(
30
- "Anthropic::Resources::Beta::Messages",
31
- with: MessagesPatch,
32
- methods: %i[create stream stream_raw],
33
- optional: true
34
- )
17
+ patch_target("Anthropic::Resources::Messages", with: MessagesPatch),
18
+ patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
19
+ patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
20
+ patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true)
35
21
  ]
36
22
  end
37
23
 
@@ -39,122 +25,122 @@ module LlmCostTracker
39
25
  return unless active?
40
26
 
41
27
  record_safely do
42
- usage = object_value(message, :usage)
28
+ usage = message.usage
43
29
  next unless usage
30
+ next if usage.input_tokens.nil? && usage.output_tokens.nil?
44
31
 
45
- input_tokens = object_value(usage, :input_tokens)
46
- output_tokens = object_value(usage, :output_tokens)
47
- next if input_tokens.nil? && output_tokens.nil?
32
+ usage_hash = usage.deep_to_h
48
33
 
49
34
  LlmCostTracker::Tracker.record(
50
- event: Event.build(
51
- provider: "anthropic",
52
- model: object_value(message, :model) || request[:model],
53
- pricing_mode: pricing_mode(request: request, usage: usage),
54
- token_usage: token_usage(usage: usage, input_tokens: input_tokens, output_tokens: output_tokens),
55
- usage_source: :sdk_response,
56
- provider_response_id: object_value(message, :id),
57
- service_line_items: service_line_items_from(usage)
35
+ event: Providers::Anthropic::ResponseParser.event_from_usage(
36
+ usage: usage_hash,
37
+ model: message.model || request[:model],
38
+ provider_response_id: message.id,
39
+ usage_source: Usage::Source::SDK_RESPONSE,
40
+ request: request
58
41
  ),
59
42
  latency_ms: latency_ms
60
43
  )
61
44
  end
62
45
  end
63
46
 
64
- def service_line_items_from(usage)
65
- server_tool_use = object_value(usage, :server_tool_use)
66
- return [] unless server_tool_use
47
+ def record_batch_result(response)
48
+ return unless active?
49
+ return unless response.respond_to?(:result) && response.result
50
+
51
+ result = response.result
52
+ return unless result.respond_to?(:type) && result.type.to_s == "succeeded"
67
53
 
68
- Providers::Anthropic::ServerTools::LINE_ITEMS.filter_map do |component_key, count_key|
69
- quantity = object_value(server_tool_use, count_key).to_i
70
- next if quantity.zero?
54
+ message = result.respond_to?(:message) ? result.message : nil
55
+ return unless message
56
+ return if LlmCostTracker::Call.already_recorded?(provider: "anthropic", provider_response_id: message.id)
71
57
 
72
- Billing::LineItem.build(
73
- component_key: component_key,
74
- quantity: quantity,
75
- cost_status: Billing::CostStatus::UNKNOWN,
76
- pricing_basis: :provider_usage,
77
- provider_field: "usage.server_tool_use.#{count_key}"
58
+ record_safely do
59
+ usage = message.usage
60
+ next unless usage
61
+ next if usage.input_tokens.nil? && usage.output_tokens.nil?
62
+
63
+ usage_hash = usage.deep_to_h
64
+ LlmCostTracker::Tracker.record(
65
+ event: Providers::Anthropic::ResponseParser.event_from_usage(
66
+ usage: usage_hash,
67
+ model: message.model,
68
+ provider_response_id: message.id,
69
+ usage_source: Usage::Source::SDK_BATCH_RESULT,
70
+ pricing_mode: "batch"
71
+ )
78
72
  )
79
73
  end
80
74
  end
81
75
 
82
- def token_usage(usage:, input_tokens:, output_tokens:)
83
- cache_creation = object_value(usage, :cache_creation)
84
- if cache_creation
85
- cache_write_default = object_value(cache_creation, :ephemeral_5m_input_tokens).to_i
86
- cache_write_extended = object_value(cache_creation, :ephemeral_1h_input_tokens).to_i
87
- else
88
- cache_write_default = object_value(usage, :cache_creation_input_tokens).to_i
89
- cache_write_extended = 0
90
- end
91
- hidden_output = (
92
- object_value(usage, :thinking_tokens, :thinking_output_tokens) ||
93
- object_dig(usage, :output_tokens_details, :reasoning_tokens)
94
- ).to_i
95
-
96
- TokenUsage.build(
97
- input_tokens: input_tokens.to_i,
98
- output_tokens: output_tokens.to_i,
99
- cache_read_input_tokens: object_value(usage, :cache_read_input_tokens).to_i,
100
- cache_write_input_tokens: cache_write_default,
101
- cache_write_extended_input_tokens: cache_write_extended,
102
- hidden_output_tokens: hidden_output
103
- )
76
+ def stream_pricing_mode(request)
77
+ Providers::Anthropic::UsageExtractor.pricing_mode(request: request || {}, usage: nil)
104
78
  end
79
+ end
105
80
 
106
- def pricing_mode(request:, usage:)
107
- service_tier = object_value(usage, :service_tier) || request[:service_tier]
108
- tier = Providers::Anthropic::TierClassification
109
- service_tier = nil if tier.standard_equivalent_tier?(service_tier)
110
-
111
- modes = [
112
- Pricing::Mode.normalize(object_value(usage, :speed) || request[:speed]),
113
- Pricing::Mode.normalize(service_tier)
114
- ]
115
- geo = inference_geo(request: request, usage: usage).to_s.downcase
116
- modes << "data_residency" if tier.data_residency_geo?(geo)
117
- modes = modes.compact.uniq
118
- modes.empty? ? nil : modes.join("_")
81
+ module MessagesPatch
82
+ def create(*args, **kwargs)
83
+ LlmCostTracker::Integrations::Anthropic.wrap_blocking(
84
+ args,
85
+ kwargs,
86
+ record: lambda do |message, request, latency_ms|
87
+ LlmCostTracker::Integrations::Anthropic.record_message(
88
+ message, request: request, latency_ms: latency_ms
89
+ )
90
+ end
91
+ ) { super }
119
92
  end
120
93
 
121
- def stream_pricing_mode(request)
122
- pricing_mode(request: request || {}, usage: nil)
94
+ def stream(*args, **kwargs)
95
+ LlmCostTracker::Integrations::Anthropic.wrap_stream(
96
+ args,
97
+ kwargs,
98
+ collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
99
+ ) { super }
123
100
  end
124
101
 
125
- def inference_geo(request:, usage:)
126
- object_value(usage, :inference_geo) || request[:inference_geo]
102
+ def stream_raw(*args, **kwargs)
103
+ LlmCostTracker::Integrations::Anthropic.wrap_stream(
104
+ args,
105
+ kwargs,
106
+ collector: ->(request) { LlmCostTracker::Integrations::Anthropic.stream_collector(request) }
107
+ ) { super }
127
108
  end
109
+ end
128
110
 
129
- def wrap_stream_call(args, kwargs)
130
- request = request_params(args, kwargs)
131
- enforce_budget!(request: request)
132
- collector = stream_collector(request)
133
- stream = yield
134
- track_stream(stream, collector: collector)
135
- end
111
+ module BatchesPatch
112
+ def results_streaming(*args, **kwargs)
113
+ raw = super
114
+ return raw unless LlmCostTracker::Integrations::Anthropic.active?
136
115
 
137
- def wrap_blocking_call(args, kwargs)
138
- request = request_params(args, kwargs)
139
- enforce_budget!(request: request)
140
- started_at = LlmCostTracker::Timing.now_monotonic
141
- message = yield
142
- record_message(message, request: request, latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at))
143
- message
116
+ BatchResultsCapture.new(raw)
144
117
  end
145
118
  end
146
119
 
147
- module MessagesPatch
148
- def create(*args, **kwargs)
149
- LlmCostTracker::Integrations::Anthropic.wrap_blocking_call(args, kwargs) { super }
120
+ class BatchResultsCapture
121
+ include Enumerable
122
+
123
+ def initialize(raw_stream)
124
+ @raw_stream = raw_stream
150
125
  end
151
126
 
152
- def stream(*args, **kwargs)
153
- LlmCostTracker::Integrations::Anthropic.wrap_stream_call(args, kwargs) { super }
127
+ def each(&block)
128
+ return enum_for(:each) unless block
129
+
130
+ @raw_stream.each do |response|
131
+ LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
132
+ block.call(response)
133
+ end
154
134
  end
155
135
 
156
- def stream_raw(*args, **kwargs)
157
- LlmCostTracker::Integrations::Anthropic.wrap_stream_call(args, kwargs) { super }
136
+ def respond_to_missing?(name, include_private = false)
137
+ @raw_stream.respond_to?(name, include_private) || super
138
+ end
139
+
140
+ def method_missing(name, ...)
141
+ return super unless @raw_stream.respond_to?(name)
142
+
143
+ @raw_stream.public_send(name, ...)
158
144
  end
159
145
  end
160
146
  end
@@ -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