llm_cost_tracker 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +16 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  7. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +2 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +9 -7
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +5 -26
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -0
  13. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  14. data/app/models/llm_cost_tracker/call.rb +10 -14
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +29 -30
  17. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  18. data/app/services/llm_cost_tracker/dashboard/params.rb +10 -0
  19. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  20. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  21. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  22. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  23. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  24. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  26. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  27. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  28. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  29. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  32. data/app/views/llm_cost_tracker/tags/show.html.erb +8 -5
  33. data/config/routes.rb +6 -1
  34. data/lib/llm_cost_tracker/budget/per_tag.rb +21 -11
  35. data/lib/llm_cost_tracker/budget.rb +44 -55
  36. data/lib/llm_cost_tracker/capture/event_window.rb +100 -0
  37. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  38. data/lib/llm_cost_tracker/capture/sse.rb +108 -32
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +41 -81
  40. data/lib/llm_cost_tracker/capture/stream_tap.rb +57 -0
  41. data/lib/llm_cost_tracker/capture/stream_tracker.rb +14 -58
  42. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  43. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  44. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  45. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  46. data/lib/llm_cost_tracker/configuration.rb +5 -9
  47. data/lib/llm_cost_tracker/doctor/price_check.rb +14 -3
  48. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  49. data/lib/llm_cost_tracker/doctor.rb +19 -2
  50. data/lib/llm_cost_tracker/engine.rb +0 -1
  51. data/lib/llm_cost_tracker/errors.rb +13 -0
  52. data/lib/llm_cost_tracker/event.rb +8 -0
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -12
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  70. data/lib/llm_cost_tracker/ingestion/batch.rb +37 -8
  71. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  72. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  73. data/lib/llm_cost_tracker/integrations/anthropic.rb +70 -11
  74. data/lib/llm_cost_tracker/integrations/base.rb +56 -19
  75. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +21 -13
  76. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  77. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  78. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +275 -75
  79. data/lib/llm_cost_tracker/integrations.rb +1 -20
  80. data/lib/llm_cost_tracker/ledger/isolation.rb +23 -0
  81. data/lib/llm_cost_tracker/ledger/period/totals.rb +16 -11
  82. data/lib/llm_cost_tracker/ledger/rollups.rb +25 -17
  83. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  84. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  85. data/lib/llm_cost_tracker/ledger/storable.rb +16 -0
  86. data/lib/llm_cost_tracker/ledger/store.rb +14 -13
  87. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  88. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  89. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  90. data/lib/llm_cost_tracker/ledger.rb +1 -0
  91. data/lib/llm_cost_tracker/logging.rb +3 -1
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +67 -61
  93. data/lib/llm_cost_tracker/parsers.rb +12 -41
  94. data/lib/llm_cost_tracker/prices.json +3071 -249
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  100. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  102. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +17 -17
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  105. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +6 -1
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  107. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +47 -0
  108. data/lib/llm_cost_tracker/pricing/sync.rb +38 -70
  109. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  110. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  111. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  112. data/lib/llm_cost_tracker/providers/azure/parser.rb +22 -7
  113. data/lib/llm_cost_tracker/providers/gemini/parser.rb +175 -47
  114. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  115. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  116. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  117. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +101 -27
  118. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  119. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +24 -6
  120. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +7 -1
  121. data/lib/llm_cost_tracker/redaction.rb +32 -0
  122. data/lib/llm_cost_tracker/report/data.rb +1 -1
  123. data/lib/llm_cost_tracker/retention.rb +1 -3
  124. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -36
  125. data/lib/llm_cost_tracker/tracker.rb +17 -10
  126. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  127. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  128. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  129. data/lib/llm_cost_tracker/version.rb +1 -1
  130. data/lib/llm_cost_tracker.rb +4 -2
  131. data/lib/tasks/llm_cost_tracker.rake +26 -21
  132. data/llm_cost_tracker.gemspec +63 -0
  133. metadata +23 -10
@@ -18,12 +18,6 @@ module LlmCostTracker
18
18
  "db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
19
19
  )
20
20
  end
21
-
22
- private
23
-
24
- def migration_version
25
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -10,8 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Adds a partial index over unpriced calls and drops the unused ingestion inbox lock index" \
14
- "scanning the whole ledger."
13
+ desc "Adds the unpriced-calls index (partial on PostgreSQL, so backfill_unknown_pricing stops scanning " \
14
+ "the whole ledger; a plain id index on MySQL, which has no partial indexes) and drops the unused " \
15
+ "ingestion inbox lock index."
15
16
 
16
17
  def create_migration_file
17
18
  migration_template(
@@ -19,12 +20,6 @@ module LlmCostTracker
19
20
  "db/migrate/upgrade_llm_cost_tracker_indexes.rb"
20
21
  )
21
22
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
23
  end
29
24
  end
30
25
  end
@@ -10,8 +10,9 @@ module LlmCostTracker
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Carries each call's cost and time onto its tag rows so per-tag budgets read " \
14
- "llm_cost_tracker_call_tags without a join. Required when config.budgets.per_tag is set."
13
+ desc "Adds cost and time columns to llm_cost_tracker_call_tags so per-tag budgets read it " \
14
+ "without a join; run llm_cost_tracker:backfill_tag_costs afterwards so earlier calls count. " \
15
+ "Required for config.budgets.per_tag on installs created before v0.14."
15
16
 
16
17
  def create_migration_file
17
18
  migration_template(
@@ -19,12 +20,6 @@ module LlmCostTracker
19
20
  "db/migrate/upgrade_llm_cost_tracker_per_tag_budgets.rb"
20
21
  )
21
22
  end
22
-
23
- private
24
-
25
- def migration_version
26
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
- end
28
23
  end
29
24
  end
30
25
  end
@@ -13,7 +13,8 @@ module LlmCostTracker
13
13
  ActiveRecord::Deadlocked,
14
14
  ActiveRecord::LockWaitTimeout,
15
15
  ActiveRecord::StatementTimeout,
16
- ActiveRecord::ConnectionNotEstablished
16
+ ActiveRecord::ConnectionNotEstablished,
17
+ LlmCostTracker::TransactionAbortedError
17
18
  ].freeze
18
19
 
19
20
  def initialize(identity:)
@@ -25,7 +26,7 @@ module LlmCostTracker
25
26
  return 0 if rows.empty?
26
27
 
27
28
  valid_rows, events = decode(rows)
28
- persist(valid_rows, events) if events.any?
29
+ persist_batch(valid_rows, events) if events.any?
29
30
  rows.size
30
31
  rescue StandardError => e
31
32
  rows_to_mark = valid_rows&.any? ? valid_rows : rows
@@ -67,7 +68,7 @@ module LlmCostTracker
67
68
  end
68
69
 
69
70
  def error_message_for(error)
70
- "#{error.class}: #{error.message}".byteslice(0, 1_000)
71
+ "#{error.class}: #{Redaction.text(error.message)}".byteslice(0, 1_000).scrub("")
71
72
  end
72
73
 
73
74
  def warn_on_quarantine(rows)
@@ -75,12 +76,10 @@ module LlmCostTracker
75
76
  quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
76
77
  return if quarantined.empty?
77
78
 
78
- sample = quarantined.first(10).map(&:id).join(", ")
79
- sample += "..." if quarantined.size > 10
80
79
  LlmCostTracker::Logging.warn(
81
80
  "Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
82
81
  "MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
83
- "on the next claim cycle (ids: #{sample})"
82
+ "on the next claim cycle (ids: #{id_sample(quarantined)})"
84
83
  )
85
84
  end
86
85
 
@@ -88,6 +87,11 @@ module LlmCostTracker
88
87
 
89
88
  attr_reader :identity
90
89
 
90
+ def id_sample(rows)
91
+ sample = rows.first(10).map(&:id).join(", ")
92
+ rows.size > 10 ? "#{sample}..." : sample
93
+ end
94
+
91
95
  def claim
92
96
  now = Time.now.utc
93
97
  cutoff = now - LOCK_TIMEOUT_SECONDS
@@ -117,13 +121,38 @@ module LlmCostTracker
117
121
  [valid_rows, events]
118
122
  end
119
123
 
124
+ def persist_batch(rows, events)
125
+ landed = []
126
+ failed = Hash.new { |hash, message| hash[message] = [] }
127
+ landed.concat(persist(rows, events))
128
+ rescue *TRANSIENT_PERSIST_ERRORS
129
+ raise
130
+ rescue StandardError
131
+ rows.zip(events) do |row, event|
132
+ landed.concat(persist([row], [event]))
133
+ rescue *TRANSIENT_PERSIST_ERRORS
134
+ raise
135
+ rescue StandardError => e
136
+ failed[error_message_for(e)] << row
137
+ end
138
+ ensure
139
+ failed.each do |message, failed_rows|
140
+ LlmCostTracker::Logging.warn(
141
+ "Ingestion::Batch: #{failed_rows.size} inbox row(s) could not be stored " \
142
+ "(ids: #{id_sample(failed_rows)}): #{message}"
143
+ )
144
+ mark_failed_with_message(failed_rows, message)
145
+ end
146
+ Ledger::Rollups.increment_safely!(landed)
147
+ Budget.notify_persisted_safely!(landed)
148
+ end
149
+
120
150
  def persist(rows, events, retry_on_conflict: true)
121
151
  LlmCostTracker::Call.transaction do
122
152
  Ledger::Store.persist_records(events)
123
153
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
124
154
  end
125
- Ledger::Rollups.increment_safely!(events)
126
- Budget.notify_persisted_safely!(events)
155
+ events
127
156
  rescue ActiveRecord::RecordNotUnique
128
157
  raise unless retry_on_conflict
129
158
 
@@ -18,7 +18,7 @@ module LlmCostTracker
18
18
  end
19
19
 
20
20
  def event_from_row(row)
21
- payload = JSON.parse(row.payload, symbolize_names: true)
21
+ payload = JSON.parse(row.payload, symbolize_names: true, allow_duplicate_key: true)
22
22
  schema_version = payload[:schema_version]
23
23
  unless schema_version == PAYLOAD_SCHEMA_VERSION
24
24
  raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
@@ -69,13 +69,7 @@ module LlmCostTracker
69
69
  end
70
70
 
71
71
  def payload_for(event)
72
- event.to_h.merge(
73
- schema_version: PAYLOAD_SCHEMA_VERSION,
74
- event_id: event.event_id,
75
- provider: event.provider,
76
- model: event.model,
77
- tracked_at: event.tracked_at.iso8601(6)
78
- )
72
+ event.to_h.merge(schema_version: PAYLOAD_SCHEMA_VERSION, tracked_at: event.tracked_at.iso8601(6))
79
73
  end
80
74
 
81
75
  def insert_row(row)
@@ -28,6 +28,8 @@ module LlmCostTracker
28
28
  end
29
29
 
30
30
  guards_for_current_config.each do |schema_module, table_name|
31
+ next if schema_module == Ledger::Schema::CallRollups && !Ledger::Rollups.cache_active?
32
+
31
33
  errors = schema_module.current_schema_errors
32
34
  next if errors.empty?
33
35
 
@@ -124,7 +126,7 @@ module LlmCostTracker
124
126
 
125
127
  def cleanup_verification_call(response_id)
126
128
  relation = LlmCostTracker::Call.where(provider_response_id: response_id)
127
- records = relation.select(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider).to_a
129
+ records = relation.select(:id, *LlmCostTracker::Ledger::Rollups::SOURCE_COLUMNS).to_a
128
130
  return if records.empty?
129
131
 
130
132
  relation.delete_all
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../providers/anthropic/usage_extractor"
5
4
  require_relative "../providers/anthropic/response_parser"
6
5
 
7
6
  module LlmCostTracker
@@ -17,7 +16,8 @@ module LlmCostTracker
17
16
  patch_target("Anthropic::Resources::Messages", with: MessagesPatch),
18
17
  patch_target("Anthropic::Resources::Beta::Messages", with: MessagesPatch, optional: true),
19
18
  patch_target("Anthropic::Resources::Messages::Batches", with: BatchesPatch, optional: true),
20
- patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true)
19
+ patch_target("Anthropic::Resources::Beta::Messages::Batches", with: BatchesPatch, optional: true),
20
+ patch_target("Anthropic::BetaRefusalFallbackMiddleware", with: FallbackMiddlewarePatch, optional: true)
21
21
  ]
22
22
  end
23
23
 
@@ -37,7 +37,8 @@ module LlmCostTracker
37
37
  model: message.model || request[:model],
38
38
  provider_response_id: message.id,
39
39
  usage_source: Usage::Source::SDK_RESPONSE,
40
- request: request
40
+ request: request,
41
+ **response_fields(message)
41
42
  ),
42
43
  latency_ms: latency_ms
43
44
  )
@@ -61,21 +62,52 @@ module LlmCostTracker
61
62
  next unless usage
62
63
  next if usage.input_tokens.nil? && usage.output_tokens.nil?
63
64
 
64
- usage_hash = usage.deep_to_h
65
- LlmCostTracker::Tracker.record(
66
- event: Providers::Anthropic::ResponseParser.event_from_usage(
67
- usage: usage_hash,
65
+ record_once(
66
+ Providers::Anthropic::ResponseParser.event_from_usage(
67
+ usage: usage.deep_to_h.merge(service_tier: "batch"),
68
68
  model: message.model,
69
69
  provider_response_id: message.id,
70
70
  usage_source: Usage::Source::SDK_BATCH_RESULT,
71
- pricing_mode: "batch"
71
+ **response_fields(message)
72
72
  )
73
73
  )
74
74
  end
75
75
  end
76
76
 
77
- def stream_pricing_mode(request)
78
- Providers::Anthropic::UsageExtractor.pricing_mode(request: request || {}, usage: nil)
77
+ def record_refused_hop(request, response)
78
+ record_safely { record_message(response.parse, request: request.body, latency_ms: nil) }
79
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
80
+ nil
81
+ end
82
+
83
+ def record_refused_stream_hop(hop)
84
+ return unless active?
85
+
86
+ usage = hop.dig(:refused, :usage).to_h.deep_symbolize_keys
87
+ output = usage[:output_tokens].to_i.positive?
88
+ usage = usage.slice(:server_tool_use) if output
89
+ return if output && Providers::Anthropic::UsageExtractor.service_line_items(usage).empty?
90
+
91
+ record_safely do
92
+ LlmCostTracker::Tracker.record(
93
+ event: Providers::Anthropic::ResponseParser.event_from_usage(
94
+ usage: usage,
95
+ model: hop[:model],
96
+ provider_response_id: nil,
97
+ usage_source: Usage::Source::STREAM_FINAL,
98
+ stream: true,
99
+ stop_reason: ("refusal" unless output),
100
+ refusal_category: hop.dig(:refused, :stop_details, "category")
101
+ )
102
+ )
103
+ end
104
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
105
+ nil
106
+ end
107
+
108
+ def response_fields(message)
109
+ { stop_reason: message.stop_reason, refusal_category: message.stop_details&.category,
110
+ content: message.deep_to_h[:content] }
79
111
  end
80
112
  end
81
113
 
@@ -109,6 +141,27 @@ module LlmCostTracker
109
141
  end
110
142
  end
111
143
 
144
+ module FallbackMiddlewarePatch
145
+ def call(req, nxt)
146
+ return super if req.streaming? || !LlmCostTracker::Integrations::Anthropic.active?
147
+
148
+ hops = []
149
+ response = super(req, ->(hop_req) { nxt.call(hop_req).tap { |hop| hops << [hop_req, hop] } })
150
+ answered = hops.select { |_hop_req, hop| hop.status < 300 }
151
+ answered[...-1].each { |hop| LlmCostTracker::Integrations::Anthropic.record_refused_hop(*hop) }
152
+ response
153
+ end
154
+
155
+ private
156
+
157
+ def consume_hop(*args, **kwargs)
158
+ refused = Thread.current[:llm_cost_tracker_refused_hop]
159
+ Thread.current[:llm_cost_tracker_refused_hop] = nil
160
+ LlmCostTracker::Integrations::Anthropic.record_refused_stream_hop(refused) if refused && kwargs[:splice]
161
+ super.tap { |hop| Thread.current[:llm_cost_tracker_refused_hop] = hop if hop[:refused] }
162
+ end
163
+ end
164
+
112
165
  module BatchesPatch
113
166
  def results_streaming(*args, **kwargs)
114
167
  raw = super
@@ -128,10 +181,16 @@ module LlmCostTracker
128
181
  def each(&block)
129
182
  return enum_for(:each) unless block
130
183
 
184
+ deferred = nil
131
185
  @raw_stream.each do |response|
132
- LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
186
+ begin
187
+ LlmCostTracker::Integrations::Anthropic.record_batch_result(response)
188
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
189
+ deferred ||= e
190
+ end
133
191
  block.call(response)
134
192
  end
193
+ raise deferred if deferred
135
194
  end
136
195
 
137
196
  def respond_to_missing?(name, include_private = false)
@@ -16,20 +16,19 @@ module LlmCostTracker
16
16
  @integration_name ||= name.demodulize.underscore.to_sym
17
17
  end
18
18
 
19
- def provider(value = nil)
20
- @provider = value.to_s if value
21
- @provider ||= integration_name.to_s
22
- end
19
+ def provider = integration_name.to_s
23
20
 
24
21
  def active?
25
- LlmCostTracker.configuration.instrumented?(integration_name)
22
+ LlmCostTracker.configuration.enabled && LlmCostTracker.configuration.instrumented?(integration_name)
26
23
  end
27
24
 
28
25
  def install
29
26
  validate_contract!
27
+ Logging.warn(untested_version_message) if untested_version?
30
28
  patch_targets.each do |target|
31
29
  target_class = target.fetch(:constant_name).to_s.safe_constantize
32
- install_patch(target_class, target.fetch(:patch)) if target_class
30
+ patch = target.fetch(:patch)
31
+ target_class.prepend(patch) if target_class && !target_class.ancestors.include?(patch)
33
32
  end
34
33
  end
35
34
 
@@ -39,6 +38,7 @@ module LlmCostTracker
39
38
  if problems.any?
40
39
  return Check.new(:warn, name, "#{name} integration cannot be installed: #{problems.join('; ')}")
41
40
  end
41
+ return Check.new(:warn, name, untested_version_message) if untested_version?
42
42
 
43
43
  installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
44
44
  target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
@@ -60,12 +60,46 @@ module LlmCostTracker
60
60
 
61
61
  def record_safely
62
62
  yield
63
- rescue LlmCostTracker::Error
63
+ rescue *LlmCostTracker::CALLER_ERRORS
64
64
  raise
65
65
  rescue StandardError => e
66
66
  Logging.warn("#{integration_name} integration failed to record usage: #{e.class}: #{e.message}")
67
67
  end
68
68
 
69
+ def record_passthrough(provider:,
70
+ model:,
71
+ response:,
72
+ latency_ms:,
73
+ service_line_items: [],
74
+ usage_source: LlmCostTracker::Usage::Source::SDK_RESPONSE,
75
+ pricing_mode: nil,
76
+ **token_attributes)
77
+ return unless active?
78
+
79
+ record_safely do
80
+ LlmCostTracker::Tracker.record(
81
+ event: LlmCostTracker::Event.build(
82
+ provider: provider,
83
+ model: model,
84
+ token_usage: LlmCostTracker::Usage::TokenUsage.build(**token_attributes),
85
+ usage_source: usage_source,
86
+ pricing_mode: pricing_mode,
87
+ provider_response_id: provider_response_id_for(response),
88
+ service_line_items: service_line_items
89
+ ),
90
+ latency_ms: latency_ms
91
+ )
92
+ end
93
+ end
94
+
95
+ def record_once(event)
96
+ LlmCostTracker::Tracker.record(event: event.keyed_by_response_id)
97
+ rescue ActiveRecord::RecordNotUnique
98
+ nil
99
+ end
100
+
101
+ def provider_response_id_for(response) = response&.try(:id)
102
+
69
103
  def request_params(args, kwargs)
70
104
  params =
71
105
  case args.first
@@ -106,24 +140,24 @@ module LlmCostTracker
106
140
  ).wrap
107
141
  end
108
142
 
109
- def stream_collector(request, provider: self.provider)
143
+ def stream_collector(request)
110
144
  LlmCostTracker::Capture::StreamCollector.new(
111
145
  provider: provider,
112
146
  model: request[:model],
113
- pricing_mode: stream_pricing_mode(request),
114
147
  request: request
115
148
  )
116
149
  end
117
150
 
118
- def stream_pricing_mode(_request)
119
- nil
120
- end
121
-
122
151
  def minimum_version(value = nil)
123
152
  @minimum_version = value if value
124
153
  @minimum_version
125
154
  end
126
155
 
156
+ def maximum_version(value = nil)
157
+ @maximum_version = value if value
158
+ @maximum_version
159
+ end
160
+
127
161
  def gem_version
128
162
  Gem.loaded_specs[integration_name.to_s]&.version
129
163
  end
@@ -160,6 +194,15 @@ module LlmCostTracker
160
194
  ["#{name} >= #{minimum_version} is required, detected #{version}"]
161
195
  end
162
196
 
197
+ def untested_version?
198
+ maximum_version && gem_version && gem_version >= Gem::Version.new(maximum_version)
199
+ end
200
+
201
+ def untested_version_message
202
+ "#{integration_name} #{gem_version} is newer than the tested range (< #{maximum_version}); " \
203
+ "its calls may not be recorded"
204
+ end
205
+
163
206
  def target_problems
164
207
  patch_targets.flat_map do |target|
165
208
  constant_name = target.fetch(:constant_name)
@@ -180,12 +223,6 @@ module LlmCostTracker
180
223
  "#{target.fetch(:constant_name)}##{method_name} is not available"
181
224
  end
182
225
  end
183
-
184
- def install_patch(target, patch)
185
- return if target&.ancestors&.include?(patch)
186
-
187
- target.prepend(patch)
188
- end
189
226
  end
190
227
  end
191
228
  end
@@ -13,16 +13,17 @@ module LlmCostTracker
13
13
  class << self
14
14
  def maybe_capture(batch, resource:)
15
15
  return unless Openai.active?
16
- return unless batch.status.to_s == "completed"
16
+ return unless %w[completed expired cancelled].include?(batch.status.to_s)
17
17
  return unless batch.output_file_id && batch.id
18
18
  return if captured?(batch.id)
19
19
 
20
20
  client = resource.instance_variable_get(:@client)
21
21
  host = Openai.client_host_for(resource)
22
22
  Openai.record_safely do
23
- io = client.files.content(batch.output_file_id)
24
- capture_jsonl(io.respond_to?(:read) ? io.read : io.to_s, host: host)
23
+ jsonl = client.files.content(batch.output_file_id).read
24
+ deferred = capture_jsonl(jsonl, host: host, model: batch.model)
25
25
  mark_captured(batch.id)
26
+ raise deferred if deferred
26
27
  end
27
28
  end
28
29
 
@@ -40,19 +41,20 @@ module LlmCostTracker
40
41
  end
41
42
  end
42
43
 
43
- def capture_jsonl(jsonl, host:)
44
+ def capture_jsonl(jsonl, host:, model:)
45
+ deferred = nil
44
46
  jsonl.each_line do |line|
45
- line = line.strip
46
- next if line.empty?
47
-
48
47
  entry = parse_line(line)
49
48
  next unless entry
50
49
 
51
50
  response = entry.dig("response", "body")
52
51
  next unless response.is_a?(Hash) && response["usage"]
53
52
 
54
- record_result(response, host: host)
53
+ record_result({ "id" => entry["id"] }.merge(response), host: host, model: model)
54
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError => e
55
+ deferred ||= e
55
56
  end
57
+ deferred
56
58
  end
57
59
 
58
60
  def parse_line(line)
@@ -61,19 +63,25 @@ module LlmCostTracker
61
63
  nil
62
64
  end
63
65
 
64
- def record_result(response, host:)
66
+ def record_result(response, host:, model:)
65
67
  provider = Openai.provider_for_host(host)
66
68
  return if LlmCostTracker::Call.already_recorded?(provider: provider, provider_response_id: response["id"])
67
69
 
68
- event = LlmCostTracker::Providers::Openai::ResponseParser.event_from_response(
70
+ parser = LlmCostTracker::Providers::Openai::ResponseParser
71
+ event = parser.event_from_response(
69
72
  response: response,
70
- request: {},
73
+ request: { "model" => model },
71
74
  provider: provider,
72
75
  host: host,
73
76
  usage_source: LlmCostTracker::Usage::Source::SDK_BATCH_RESULT,
74
- pricing_mode: "batch"
77
+ # /v1/batches runs regional processing only on the us and eu hosts.
78
+ pricing_mode: parser.combined_pricing_mode(
79
+ host: (host if host.to_s.match?(/\A(?:us|eu)\./i)),
80
+ model: response["model"] || model,
81
+ service_tier: "batch"
82
+ )
75
83
  )
76
- LlmCostTracker::Tracker.record(event: event) if event
84
+ Openai.record_once(event)
77
85
  end
78
86
  end
79
87
  end
@@ -45,6 +45,14 @@ module LlmCostTracker
45
45
  include PatchBuilder.build(record_method: :record_response, methods: %i[create])
46
46
  include PatchBuilder.build_stream(methods: %i[stream stream_raw])
47
47
 
48
+ def retrieve(response_id, *args, **kwargs)
49
+ response = super
50
+ LlmCostTracker::Integrations::Openai.record_retrieved_response(
51
+ response, host: LlmCostTracker::Integrations::Openai.client_host_for(self)
52
+ )
53
+ response
54
+ end
55
+
48
56
  def retrieve_streaming(response_id, *args, **kwargs)
49
57
  LlmCostTracker::Integrations::Openai.wrap_stream(
50
58
  args, kwargs, **LlmCostTracker::Integrations::Openai.stream_seam(self)