llm_cost_tracker 0.8.0 → 0.10.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -2,10 +2,10 @@
2
2
 
3
3
  require "securerandom"
4
4
 
5
- require_relative "doctor/check"
6
5
  require_relative "errors"
7
6
  require_relative "ledger"
8
7
  require_relative "ingestion/lease_claim"
8
+ require_relative "ingestion/pool"
9
9
  require_relative "ingestion/inbox"
10
10
  require_relative "ingestion/batch"
11
11
  require_relative "ingestion/worker"
@@ -19,11 +19,17 @@ module LlmCostTracker
19
19
  "llm_cost_tracker_ingestion_"
20
20
  end
21
21
 
22
- WRITE_SCHEMA_GUARDS = [
23
- ["llm_cost_tracker_calls", Ledger::Schema::Calls],
24
- ["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
25
- ["llm_cost_tracker_call_tags", Ledger::Schema::CallTags],
26
- ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups]
22
+ CORE_SCHEMA_GUARDS = [
23
+ ["llm_cost_tracker_calls", Ledger::Schema::Calls],
24
+ ["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
25
+ ["llm_cost_tracker_call_tags", Ledger::Schema::CallTags]
26
+ ].freeze
27
+
28
+ ROLLUPS_SCHEMA_GUARD = ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups].freeze
29
+
30
+ ASYNC_SCHEMA_GUARDS = [
31
+ ["llm_cost_tracker_ingestion_inbox_entries", Ledger::Schema::IngestionInboxEntries],
32
+ ["llm_cost_tracker_ingestion_leases", Ledger::Schema::IngestionLeases]
27
33
  ].freeze
28
34
 
29
35
  def ensure_current_schema!
@@ -31,7 +37,7 @@ module LlmCostTracker
31
37
  raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
32
38
  end
33
39
 
34
- WRITE_SCHEMA_GUARDS.each do |table_name, schema_module|
40
+ guards_for_current_config.each do |table_name, schema_module|
35
41
  errors = schema_module.current_schema_errors
36
42
  next if errors.empty?
37
43
 
@@ -40,6 +46,21 @@ module LlmCostTracker
40
46
  end
41
47
  end
42
48
 
49
+ def async?
50
+ LlmCostTracker.configuration.ingestion == :async
51
+ end
52
+
53
+ def cache_rollups?
54
+ LlmCostTracker.configuration.cache_rollups
55
+ end
56
+
57
+ def guards_for_current_config
58
+ guards = CORE_SCHEMA_GUARDS.dup
59
+ guards << ROLLUPS_SCHEMA_GUARD if cache_rollups?
60
+ guards += ASYNC_SCHEMA_GUARDS if async?
61
+ guards
62
+ end
63
+
43
64
  def verify
44
65
  unless LlmCostTracker::Call.table_exists?
45
66
  return [
@@ -71,7 +92,7 @@ module LlmCostTracker
71
92
  provider_response_id: response_id,
72
93
  tags: { feature: VERIFY_TAG }
73
94
  )
74
- LlmCostTracker::Ingestion::Worker.flush!
95
+ LlmCostTracker::Ingestion::Worker.flush! if async?
75
96
  persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
76
97
 
77
98
  return capture_success if persisted && notifications.any?
@@ -89,7 +110,7 @@ module LlmCostTracker
89
110
  LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
90
111
  ensure
91
112
  cleanup_verification_call(response_id) if response_id
92
- LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all if event
113
+ cleanup_verification_inbox(event: event, response_id: response_id)
93
114
  ActiveSupport::Notifications.unsubscribe(subscription) if subscription
94
115
  end
95
116
 
@@ -100,10 +121,11 @@ module LlmCostTracker
100
121
  end
101
122
 
102
123
  def capture_success
124
+ path = async? ? "async inbox" : "inline writer"
103
125
  LlmCostTracker::Doctor::Check.new(
104
126
  :ok,
105
127
  "active_record capture",
106
- "manual event emitted and persisted through durable inbox"
128
+ "manual event emitted and persisted through #{path}"
107
129
  )
108
130
  end
109
131
 
@@ -116,13 +138,28 @@ module LlmCostTracker
116
138
 
117
139
  def cleanup_verification_call(response_id)
118
140
  relation = LlmCostTracker::Call.where(provider_response_id: response_id)
119
- rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
141
+ rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
120
142
  return if rows.empty?
121
143
 
122
144
  relation.delete_all
145
+ return unless cache_rollups?
146
+
123
147
  LlmCostTracker::Ledger::Rollups.decrement!(rows)
124
148
  end
125
149
 
150
+ def cleanup_verification_inbox(event:, response_id:)
151
+ return unless async? && LlmCostTracker::Ingestion::InboxEntry.table_exists?
152
+
153
+ if event
154
+ LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all
155
+ elsif response_id
156
+ escaped = ActiveRecord::Base.sanitize_sql_like(response_id)
157
+ LlmCostTracker::Ingestion::InboxEntry
158
+ .where("payload LIKE ?", "%\"provider_response_id\":\"#{escaped}\"%")
159
+ .delete_all
160
+ end
161
+ end
162
+
126
163
  def sample_priced_identity
127
164
  key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
128
165
  model_id.include?("/") && prices[:input] && prices[:output]
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "../billing/line_item"
5
+ require_relative "../providers/anthropic/tier_classification"
5
6
 
6
7
  module LlmCostTracker
7
8
  module Integrations
@@ -45,10 +46,10 @@ module LlmCostTracker
45
46
  next if input_tokens.nil? && output_tokens.nil?
46
47
 
47
48
  LlmCostTracker::Tracker.record(
48
- capture: UsageCapture.build(
49
+ event: Event.build(
49
50
  provider: "anthropic",
50
51
  model: object_value(message, :model) || request[:model],
51
- pricing_mode: pricing_mode(message: message, request: request, usage: usage),
52
+ pricing_mode: pricing_mode(request: request, usage: usage),
52
53
  token_usage: token_usage(usage: usage, input_tokens: input_tokens, output_tokens: output_tokens),
53
54
  usage_source: :sdk_response,
54
55
  provider_response_id: object_value(message, :id),
@@ -66,6 +67,8 @@ module LlmCostTracker
66
67
  [
67
68
  line_item_for_server_tool(server_tool_use, :web_search_request, :web_search_requests,
68
69
  "usage.server_tool_use.web_search_requests"),
70
+ line_item_for_server_tool(server_tool_use, :web_fetch_request, :web_fetch_requests,
71
+ "usage.server_tool_use.web_fetch_requests"),
69
72
  line_item_for_server_tool(server_tool_use, :code_execution_request, :code_execution_requests,
70
73
  "usage.server_tool_use.code_execution_requests")
71
74
  ].compact
@@ -108,56 +111,58 @@ module LlmCostTracker
108
111
  )
109
112
  end
110
113
 
111
- def pricing_mode(message:, request:, usage:)
114
+ def pricing_mode(request:, usage:)
115
+ service_tier = object_value(usage, :service_tier) || request[:service_tier]
116
+ tier = Providers::Anthropic::TierClassification
117
+ service_tier = nil if tier.standard_equivalent_tier?(service_tier)
118
+
112
119
  modes = [
113
- Pricing.normalize_mode(object_value(usage, :speed) || object_value(message, :speed) || request[:speed]),
114
- Pricing.normalize_mode(
115
- object_value(usage, :service_tier) || object_value(message, :service_tier) || request[:service_tier]
116
- )
120
+ Pricing.normalize_mode(object_value(usage, :speed) || request[:speed]),
121
+ Pricing.normalize_mode(service_tier)
117
122
  ]
118
- modes << "data_residency" if inference_geo(message: message, request: request, usage: usage).to_s == "us"
123
+ geo = inference_geo(request: request, usage: usage).to_s.downcase
124
+ modes << "data_residency" if tier.data_residency_geo?(geo)
119
125
  modes = modes.compact.uniq
120
126
  modes.empty? ? nil : modes.join("_")
121
127
  end
122
128
 
123
129
  def stream_pricing_mode(request)
124
- pricing_mode(message: nil, request: request || {}, usage: nil)
130
+ pricing_mode(request: request || {}, usage: nil)
131
+ end
132
+
133
+ def inference_geo(request:, usage:)
134
+ object_value(usage, :inference_geo) || request[:inference_geo]
125
135
  end
126
136
 
127
- def inference_geo(message:, request:, usage:)
128
- object_value(usage, :inference_geo) ||
129
- object_value(message, :inference_geo) ||
130
- request[:inference_geo]
137
+ def wrap_stream_call(args, kwargs)
138
+ request = request_params(args, kwargs)
139
+ enforce_budget!(request: request)
140
+ collector = stream_collector(request)
141
+ stream = yield
142
+ track_stream(stream, collector: collector)
131
143
  end
132
144
  end
133
145
 
134
146
  module MessagesPatch
135
147
  def create(*args, **kwargs)
136
- LlmCostTracker::Integrations::Anthropic.enforce_budget!
148
+ request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
149
+ LlmCostTracker::Integrations::Anthropic.enforce_budget!(request: request)
137
150
  started_at = LlmCostTracker::Timing.now_monotonic
138
151
  message = super
139
152
  LlmCostTracker::Integrations::Anthropic.record_message(
140
153
  message,
141
- request: LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs),
142
- latency_ms: LlmCostTracker::Integrations::Anthropic.elapsed_ms(started_at)
154
+ request: request,
155
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at)
143
156
  )
144
157
  message
145
158
  end
146
159
 
147
160
  def stream(*args, **kwargs)
148
- request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
149
- LlmCostTracker::Integrations::Anthropic.enforce_budget!
150
- collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
151
- stream = super
152
- LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
161
+ LlmCostTracker::Integrations::Anthropic.wrap_stream_call(args, kwargs) { super }
153
162
  end
154
163
 
155
164
  def stream_raw(*args, **kwargs)
156
- request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
157
- LlmCostTracker::Integrations::Anthropic.enforce_budget!
158
- collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
159
- stream = super
160
- LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
165
+ LlmCostTracker::Integrations::Anthropic.wrap_stream_call(args, kwargs) { super }
161
166
  end
162
167
  end
163
168
  end
@@ -3,6 +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
7
  require_relative "../logging"
7
8
  require_relative "../timing"
8
9
  require_relative "../capture/stream_collector"
@@ -11,7 +12,7 @@ require_relative "../capture/stream_tracker"
11
12
  module LlmCostTracker
12
13
  module Integrations
13
14
  module Base
14
- Result = Data.define(:name, :status, :message)
15
+ Result = LlmCostTracker::Doctor::Check
15
16
 
16
17
  def active?
17
18
  LlmCostTracker.configuration.instrumented?(integration_name)
@@ -26,26 +27,28 @@ module LlmCostTracker
26
27
  end
27
28
 
28
29
  def status
29
- name = integration_name
30
+ name = integration_name.to_s
30
31
  problems = version_problems + target_problems
31
32
  if problems.any?
32
- return Result.new(name, :warn, "#{name} integration cannot be installed: #{problems.join('; ')}")
33
+ return Result.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
33
34
  end
34
35
 
35
36
  installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
36
37
  target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
37
38
  end
38
- return Result.new(name, :ok, "#{name} integration installed") if installed
39
+ return Result.new(:ok, name, "#{name} integration installed") if installed
39
40
 
40
- Result.new(name, :warn, "#{name} integration is enabled but not installed")
41
+ Result.new(:warn, name, "#{name} integration is enabled but not installed")
41
42
  end
42
43
 
43
- def elapsed_ms(started_at)
44
- Timing.elapsed_ms(started_at)
45
- end
44
+ def enforce_budget!(request:)
45
+ return unless active?
46
46
 
47
- def enforce_budget!
48
- LlmCostTracker::Tracker.enforce_budget! if active?
47
+ LlmCostTracker::Tracker.enforce_budget!(
48
+ provider: integration_name.to_s,
49
+ model: request[:model],
50
+ request: request
51
+ )
49
52
  end
50
53
 
51
54
  def record_safely
@@ -57,8 +60,21 @@ module LlmCostTracker
57
60
  end
58
61
 
59
62
  def request_params(args, kwargs)
60
- params = args.first.is_a?(Hash) ? args.first : {}
63
+ params =
64
+ case args.first
65
+ when Hash then args.first
66
+ when nil then {}
67
+ else args.first.to_h
68
+ end
61
69
  params.merge(kwargs).with_indifferent_access
70
+ rescue StandardError
71
+ kwargs.to_h.with_indifferent_access
72
+ end
73
+
74
+ def normalize_sdk_args(args, kwargs)
75
+ return args if args.any? || kwargs.empty?
76
+
77
+ [kwargs]
62
78
  end
63
79
 
64
80
  def track_stream(stream, collector:)
@@ -68,7 +84,7 @@ module LlmCostTracker
68
84
  stream: stream,
69
85
  collector: collector,
70
86
  active: -> { active? },
71
- finish: ->(errored:) { record_safely { collector.finish!(errored: errored) } }
87
+ finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
72
88
  ).wrap
73
89
  end
74
90
 
@@ -76,7 +92,8 @@ module LlmCostTracker
76
92
  LlmCostTracker::Capture::StreamCollector.new(
77
93
  provider: integration_name.to_s,
78
94
  model: request[:model],
79
- pricing_mode: stream_pricing_mode(request)
95
+ pricing_mode: stream_pricing_mode(request),
96
+ request: request
80
97
  )
81
98
  end
82
99
 
@@ -106,12 +123,13 @@ module LlmCostTracker
106
123
 
107
124
  def patch_targets = []
108
125
 
109
- def patch_target(constant_name, with:, methods:, optional: false)
126
+ def patch_target(constant_name, with:, methods:, optional: false, skip_when_methods_missing: false)
110
127
  {
111
128
  constant_name: constant_name,
112
129
  patch: with,
113
130
  method_names: Array(methods),
114
- optional: optional
131
+ optional: optional,
132
+ skip_when_methods_missing: skip_when_methods_missing
115
133
  }
116
134
  end
117
135
 
@@ -172,6 +190,8 @@ module LlmCostTracker
172
190
  end
173
191
 
174
192
  def missing_methods(target_class, target)
193
+ return [] if target[:skip_when_methods_missing]
194
+
175
195
  target.fetch(:method_names).filter_map do |method_name|
176
196
  next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
177
197