llm_cost_tracker 0.8.0 → 0.9.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +12 -5
  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 +5 -7
  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 +10 -0
  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 +24 -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/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  26. data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
  27. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  28. data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  31. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  32. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  34. data/config/routes.rb +3 -2
  35. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  36. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  37. data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
  38. data/lib/llm_cost_tracker/budget.rb +4 -2
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
  40. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  41. data/lib/llm_cost_tracker/configuration.rb +53 -1
  42. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  43. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  44. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
  45. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  46. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  47. data/lib/llm_cost_tracker/doctor.rb +72 -3
  48. data/lib/llm_cost_tracker/engine.rb +9 -0
  49. data/lib/llm_cost_tracker/event.rb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
  66. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
  68. data/lib/llm_cost_tracker/ingestion.rb +48 -10
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
  70. data/lib/llm_cost_tracker/integrations/base.rb +22 -5
  71. data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
  73. data/lib/llm_cost_tracker/integrations.rb +19 -1
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
  75. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
  76. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
  77. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
  78. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
  79. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
  80. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  81. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  82. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  83. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
  84. data/lib/llm_cost_tracker/ledger/store.rb +14 -14
  85. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
  87. data/lib/llm_cost_tracker/ledger.rb +2 -1
  88. data/lib/llm_cost_tracker/masking.rb +39 -0
  89. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
  90. data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
  91. data/lib/llm_cost_tracker/parsers/base.rb +5 -1
  92. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
  93. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
  97. data/lib/llm_cost_tracker/prices.json +110 -19
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
  99. data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
  100. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  101. data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
  102. data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  104. data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
  105. data/lib/llm_cost_tracker/pricing.rb +47 -19
  106. data/lib/llm_cost_tracker/railtie.rb +6 -0
  107. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  108. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  109. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  110. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  111. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  112. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  113. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  114. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  115. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  116. data/lib/llm_cost_tracker/report/data.rb +4 -1
  117. data/lib/llm_cost_tracker/retention.rb +15 -2
  118. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  119. data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
  120. data/lib/llm_cost_tracker/token_usage.rb +10 -2
  121. data/lib/llm_cost_tracker/tracker.rb +45 -18
  122. data/lib/llm_cost_tracker/version.rb +1 -1
  123. data/lib/llm_cost_tracker.rb +9 -0
  124. data/lib/tasks/llm_cost_tracker.rake +25 -2
  125. metadata +36 -1
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeCallTagsKeyValueIndexGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds a (key, value) composite index on llm_cost_tracker_call_tags " \
14
+ "so high-cardinality tag filters use an index lookup instead of a key-only scan."
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ "upgrade_call_tags_key_value_index.rb.erb",
19
+ "db/migrate/upgrade_llm_cost_tracker_call_tags_key_value_index.rb"
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def migration_version
26
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeImageTokensGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds image_input_tokens and image_output_tokens columns to llm_cost_tracker_calls."
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_image_tokens.rb.erb",
18
+ "db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -112,7 +112,6 @@ module LlmCostTracker
112
112
  quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
113
113
  quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
114
114
  table = connection.quote_table_name(InboxEntry.table_name)
115
-
116
115
  connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
117
116
  end
118
117
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ledger/store"
4
+
5
+ module LlmCostTracker
6
+ module Ingestion
7
+ module Inline
8
+ class << self
9
+ def save(event)
10
+ persist(event)
11
+ event
12
+ end
13
+
14
+ private
15
+
16
+ def persist(event)
17
+ Ledger::Store.insert_many([event])
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -18,6 +18,8 @@ module LlmCostTracker
18
18
  FLUSH_TIMEOUT_SECONDS = 10
19
19
  class << self
20
20
  def ensure_started
21
+ return unless Ingestion.durable?
22
+
21
23
  thread = mutex.synchronize do
22
24
  reset_after_fork!
23
25
  unless @thread&.alive?
@@ -34,6 +36,8 @@ module LlmCostTracker
34
36
  end
35
37
 
36
38
  def flush!(timeout: nil, require_lease: false)
39
+ return true unless Ingestion.durable?
40
+
37
41
  Ingestion.ensure_current_schema!
38
42
 
39
43
  deadline = Time.now.utc + flush_timeout_seconds(timeout)
@@ -52,6 +56,8 @@ module LlmCostTracker
52
56
  end
53
57
 
54
58
  def shutdown!(timeout: nil, drain: true)
59
+ return true unless Ingestion.durable?
60
+
55
61
  timeout ||= FLUSH_TIMEOUT_SECONDS
56
62
  thread = mutex.synchronize do
57
63
  @stop_requested = true
@@ -59,13 +65,15 @@ module LlmCostTracker
59
65
  @thread
60
66
  end
61
67
  wake_thread(thread)
62
- thread&.join([timeout, 1].min)
68
+ thread&.join(timeout)
63
69
  drain ? flush!(timeout: timeout, require_lease: true) : true
64
70
  rescue StandardError => e
65
71
  handle_error(e)
66
72
  false
67
73
  ensure
68
- mutex.synchronize { @thread = nil if @thread.equal?(thread) }
74
+ mutex.synchronize do
75
+ @thread = nil if @thread.equal?(thread) && !thread&.alive?
76
+ end
69
77
  end
70
78
 
71
79
  def reset!
@@ -5,6 +5,7 @@ require "securerandom"
5
5
  require_relative "doctor/check"
6
6
  require_relative "errors"
7
7
  require_relative "ledger"
8
+ require_relative "ingestion/inline"
8
9
  require_relative "ingestion/lease_claim"
9
10
  require_relative "ingestion/inbox"
10
11
  require_relative "ingestion/batch"
@@ -19,11 +20,17 @@ module LlmCostTracker
19
20
  "llm_cost_tracker_ingestion_"
20
21
  end
21
22
 
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]
23
+ CORE_SCHEMA_GUARDS = [
24
+ ["llm_cost_tracker_calls", Ledger::Schema::Calls],
25
+ ["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
26
+ ["llm_cost_tracker_call_tags", Ledger::Schema::CallTags]
27
+ ].freeze
28
+
29
+ ROLLUPS_SCHEMA_GUARD = ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups].freeze
30
+
31
+ DURABLE_SCHEMA_GUARDS = [
32
+ ["llm_cost_tracker_ingestion_inbox_entries", Ledger::Schema::IngestionInboxEntries],
33
+ ["llm_cost_tracker_ingestion_leases", Ledger::Schema::IngestionLeases]
27
34
  ].freeze
28
35
 
29
36
  def ensure_current_schema!
@@ -31,7 +38,7 @@ module LlmCostTracker
31
38
  raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
32
39
  end
33
40
 
34
- WRITE_SCHEMA_GUARDS.each do |table_name, schema_module|
41
+ guards_for_current_config.each do |table_name, schema_module|
35
42
  errors = schema_module.current_schema_errors
36
43
  next if errors.empty?
37
44
 
@@ -40,6 +47,21 @@ module LlmCostTracker
40
47
  end
41
48
  end
42
49
 
50
+ def durable?
51
+ LlmCostTracker.configuration.durable_ingestion
52
+ end
53
+
54
+ def cache_rollups?
55
+ LlmCostTracker.configuration.cache_rollups
56
+ end
57
+
58
+ def guards_for_current_config
59
+ guards = CORE_SCHEMA_GUARDS.dup
60
+ guards << ROLLUPS_SCHEMA_GUARD if cache_rollups?
61
+ guards += DURABLE_SCHEMA_GUARDS if durable?
62
+ guards
63
+ end
64
+
43
65
  def verify
44
66
  unless LlmCostTracker::Call.table_exists?
45
67
  return [
@@ -71,7 +93,7 @@ module LlmCostTracker
71
93
  provider_response_id: response_id,
72
94
  tags: { feature: VERIFY_TAG }
73
95
  )
74
- LlmCostTracker::Ingestion::Worker.flush!
96
+ LlmCostTracker::Ingestion::Worker.flush! if durable?
75
97
  persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
76
98
 
77
99
  return capture_success if persisted && notifications.any?
@@ -89,7 +111,7 @@ module LlmCostTracker
89
111
  LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
90
112
  ensure
91
113
  cleanup_verification_call(response_id) if response_id
92
- LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all if event
114
+ cleanup_verification_inbox(event: event, response_id: response_id)
93
115
  ActiveSupport::Notifications.unsubscribe(subscription) if subscription
94
116
  end
95
117
 
@@ -100,10 +122,11 @@ module LlmCostTracker
100
122
  end
101
123
 
102
124
  def capture_success
125
+ path = durable? ? "durable inbox" : "inline writer"
103
126
  LlmCostTracker::Doctor::Check.new(
104
127
  :ok,
105
128
  "active_record capture",
106
- "manual event emitted and persisted through durable inbox"
129
+ "manual event emitted and persisted through #{path}"
107
130
  )
108
131
  end
109
132
 
@@ -116,13 +139,28 @@ module LlmCostTracker
116
139
 
117
140
  def cleanup_verification_call(response_id)
118
141
  relation = LlmCostTracker::Call.where(provider_response_id: response_id)
119
- rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
142
+ rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
120
143
  return if rows.empty?
121
144
 
122
145
  relation.delete_all
146
+ return unless cache_rollups?
147
+
123
148
  LlmCostTracker::Ledger::Rollups.decrement!(rows)
124
149
  end
125
150
 
151
+ def cleanup_verification_inbox(event:, response_id:)
152
+ return unless durable? && LlmCostTracker::Ingestion::InboxEntry.table_exists?
153
+
154
+ if event
155
+ LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all
156
+ elsif response_id
157
+ escaped = ActiveRecord::Base.sanitize_sql_like(response_id)
158
+ LlmCostTracker::Ingestion::InboxEntry
159
+ .where("payload LIKE ?", "%\"provider_response_id\":\"#{escaped}\"%")
160
+ .delete_all
161
+ end
162
+ end
163
+
126
164
  def sample_priced_identity
127
165
  key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
128
166
  model_id.include?("/") && prices[:input] && prices[:output]
@@ -66,13 +66,15 @@ module LlmCostTracker
66
66
  [
67
67
  line_item_for_server_tool(server_tool_use, :web_search_request, :web_search_requests,
68
68
  "usage.server_tool_use.web_search_requests"),
69
+ line_item_for_server_tool(server_tool_use, :web_fetch_request, :web_fetch_requests,
70
+ "usage.server_tool_use.web_fetch_requests"),
69
71
  line_item_for_server_tool(server_tool_use, :code_execution_request, :code_execution_requests,
70
72
  "usage.server_tool_use.code_execution_requests")
71
73
  ].compact
72
74
  end
73
75
 
74
76
  def line_item_for_server_tool(server_tool_use, component_key, count_key, provider_field)
75
- quantity = object_value(server_tool_use, count_key).to_i
77
+ quantity = server_tool_count(server_tool_use, count_key)
76
78
  return nil if quantity.zero?
77
79
 
78
80
  Billing::LineItem.build(
@@ -84,6 +86,14 @@ module LlmCostTracker
84
86
  )
85
87
  end
86
88
 
89
+ def server_tool_count(server_tool_use, count_key)
90
+ direct = object_value(server_tool_use, count_key).to_i
91
+ return direct if direct.positive?
92
+ return 0 unless server_tool_use.respond_to?(:to_h)
93
+
94
+ server_tool_use.to_h[count_key].to_i
95
+ end
96
+
87
97
  def token_usage(usage:, input_tokens:, output_tokens:)
88
98
  cache_creation = object_value(usage, :cache_creation)
89
99
  if cache_creation
@@ -108,14 +118,23 @@ module LlmCostTracker
108
118
  )
109
119
  end
110
120
 
121
+ DATA_RESIDENCY_GEOS = %w[us].freeze
122
+ # Anthropic Priority Tier is committed throughput (tokens/min capacity), not a per-token
123
+ # surcharge. Treat it as standard pricing so cost_status doesn't fall to :unknown.
124
+ STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
125
+
111
126
  def pricing_mode(message:, request:, usage:)
127
+ service_tier = object_value(usage, :service_tier) ||
128
+ object_value(message, :service_tier) ||
129
+ request[:service_tier]
130
+ service_tier = nil if STANDARD_EQUIVALENT_SERVICE_TIERS.include?(service_tier.to_s)
131
+
112
132
  modes = [
113
133
  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
- )
134
+ Pricing.normalize_mode(service_tier)
117
135
  ]
118
- modes << "data_residency" if inference_geo(message: message, request: request, usage: usage).to_s == "us"
136
+ geo = inference_geo(message: message, request: request, usage: usage).to_s.downcase
137
+ modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
119
138
  modes = modes.compact.uniq
120
139
  modes.empty? ? nil : modes.join("_")
121
140
  end
@@ -57,8 +57,21 @@ module LlmCostTracker
57
57
  end
58
58
 
59
59
  def request_params(args, kwargs)
60
- params = args.first.is_a?(Hash) ? args.first : {}
60
+ params =
61
+ case args.first
62
+ when Hash then args.first
63
+ when nil then {}
64
+ else args.first.respond_to?(:to_h) ? args.first.to_h : {}
65
+ end
61
66
  params.merge(kwargs).with_indifferent_access
67
+ rescue StandardError
68
+ kwargs.to_h.with_indifferent_access
69
+ end
70
+
71
+ def normalize_sdk_args(args, kwargs)
72
+ return args if args.any? || kwargs.empty?
73
+
74
+ [kwargs]
62
75
  end
63
76
 
64
77
  def track_stream(stream, collector:)
@@ -68,7 +81,7 @@ module LlmCostTracker
68
81
  stream: stream,
69
82
  collector: collector,
70
83
  active: -> { active? },
71
- finish: ->(errored:) { record_safely { collector.finish!(errored: errored) } }
84
+ finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
72
85
  ).wrap
73
86
  end
74
87
 
@@ -76,7 +89,8 @@ module LlmCostTracker
76
89
  LlmCostTracker::Capture::StreamCollector.new(
77
90
  provider: integration_name.to_s,
78
91
  model: request[:model],
79
- pricing_mode: stream_pricing_mode(request)
92
+ pricing_mode: stream_pricing_mode(request),
93
+ request: request
80
94
  )
81
95
  end
82
96
 
@@ -106,12 +120,13 @@ module LlmCostTracker
106
120
 
107
121
  def patch_targets = []
108
122
 
109
- def patch_target(constant_name, with:, methods:, optional: false)
123
+ def patch_target(constant_name, with:, methods:, optional: false, skip_when_methods_missing: false)
110
124
  {
111
125
  constant_name: constant_name,
112
126
  patch: with,
113
127
  method_names: Array(methods),
114
- optional: optional
128
+ optional: optional,
129
+ skip_when_methods_missing: skip_when_methods_missing
115
130
  }
116
131
  end
117
132
 
@@ -172,6 +187,8 @@ module LlmCostTracker
172
187
  end
173
188
 
174
189
  def missing_methods(target_class, target)
190
+ return [] if target[:skip_when_methods_missing]
191
+
175
192
  target.fetch(:method_names).filter_map do |method_name|
176
193
  next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
177
194