llm_cost_tracker 0.7.3 → 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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -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"
@@ -15,31 +16,59 @@ module LlmCostTracker
15
16
  VERIFY_TAG = "llm_cost_tracker_verify"
16
17
 
17
18
  class << self
19
+ def table_name_prefix
20
+ "llm_cost_tracker_ingestion_"
21
+ end
22
+
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]
34
+ ].freeze
35
+
18
36
  def ensure_current_schema!
19
- unless Ledger::Call.table_exists?
20
- raise Error, "llm_api_calls table is missing; run install generator and migrate"
37
+ unless LlmCostTracker::Call.table_exists?
38
+ raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
39
+ end
40
+
41
+ guards_for_current_config.each do |table_name, schema_module|
42
+ errors = schema_module.current_schema_errors
43
+ next if errors.empty?
44
+
45
+ raise Error,
46
+ "#{table_name} table is not on the current schema: #{errors.join('; ')}; see docs/upgrading.md"
21
47
  end
48
+ end
22
49
 
23
- schema_errors = Ledger::Schema::Calls.current_schema_errors
24
- message = "llm_api_calls table is not on the current schema: #{schema_errors.join('; ')}"
25
- raise Error, message if schema_errors.any?
50
+ def durable?
51
+ LlmCostTracker.configuration.durable_ingestion
52
+ end
26
53
 
27
- period_total_errors = Ledger::Schema::PeriodTotals.current_schema_errors
28
- return if period_total_errors.empty?
54
+ def cache_rollups?
55
+ LlmCostTracker.configuration.cache_rollups
56
+ end
29
57
 
30
- message = "llm_cost_tracker_period_totals table is not on the current schema: " \
31
- "#{period_total_errors.join('; ')}; " \
32
- "run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
33
- raise Error, message
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
34
63
  end
35
64
 
36
65
  def verify
37
- unless LlmCostTracker::Ledger::Call.table_exists?
66
+ unless LlmCostTracker::Call.table_exists?
38
67
  return [
39
68
  LlmCostTracker::Doctor::Check.new(
40
69
  :error,
41
70
  "active_record",
42
- "llm_api_calls table is missing; run install generator and migrate"
71
+ "llm_cost_tracker_calls table is missing; run install generator and migrate"
43
72
  )
44
73
  ]
45
74
  end
@@ -60,13 +89,12 @@ module LlmCostTracker
60
89
  event = LlmCostTracker.track(
61
90
  provider: provider,
62
91
  model: model,
63
- input_tokens: 1,
64
- output_tokens: 1,
92
+ tokens: { input: 1, output: 1 },
65
93
  provider_response_id: response_id,
66
- feature: VERIFY_TAG
94
+ tags: { feature: VERIFY_TAG }
67
95
  )
68
- LlmCostTracker.flush!
69
- persisted = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id).exists?
96
+ LlmCostTracker::Ingestion::Worker.flush! if durable?
97
+ persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
70
98
 
71
99
  return capture_success if persisted && notifications.any?
72
100
 
@@ -83,7 +111,7 @@ module LlmCostTracker
83
111
  LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
84
112
  ensure
85
113
  cleanup_verification_call(response_id) if response_id
86
- LlmCostTracker::Ingestion::Event.where(event_id: event.event_id).delete_all if event
114
+ cleanup_verification_inbox(event: event, response_id: response_id)
87
115
  ActiveSupport::Notifications.unsubscribe(subscription) if subscription
88
116
  end
89
117
 
@@ -94,10 +122,11 @@ module LlmCostTracker
94
122
  end
95
123
 
96
124
  def capture_success
125
+ path = durable? ? "durable inbox" : "inline writer"
97
126
  LlmCostTracker::Doctor::Check.new(
98
127
  :ok,
99
128
  "active_record capture",
100
- "manual event emitted and persisted through durable inbox"
129
+ "manual event emitted and persisted through #{path}"
101
130
  )
102
131
  end
103
132
 
@@ -109,14 +138,29 @@ module LlmCostTracker
109
138
  end
110
139
 
111
140
  def cleanup_verification_call(response_id)
112
- relation = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id)
113
- rows = relation.pluck(:id, :tracked_at, :total_cost)
141
+ relation = LlmCostTracker::Call.where(provider_response_id: response_id)
142
+ rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
114
143
  return if rows.empty?
115
144
 
116
145
  relation.delete_all
146
+ return unless cache_rollups?
147
+
117
148
  LlmCostTracker::Ledger::Rollups.decrement!(rows)
118
149
  end
119
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
+
120
164
  def sample_priced_identity
121
165
  key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
122
166
  model_id.include?("/") && prices[:input] && prices[:output]
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../capture/stream_collector"
5
- require_relative "../capture/stream_tracker"
4
+ require_relative "../billing/line_item"
6
5
 
7
6
  module LlmCostTracker
8
7
  module Integrations
@@ -52,22 +51,58 @@ module LlmCostTracker
52
51
  pricing_mode: pricing_mode(message: message, request: request, usage: usage),
53
52
  token_usage: token_usage(usage: usage, input_tokens: input_tokens, output_tokens: output_tokens),
54
53
  usage_source: :sdk_response,
55
- provider_response_id: object_value(message, :id)
54
+ provider_response_id: object_value(message, :id),
55
+ service_line_items: service_line_items_from(usage)
56
56
  ),
57
57
  latency_ms: latency_ms
58
58
  )
59
59
  end
60
60
  end
61
61
 
62
+ def service_line_items_from(usage)
63
+ server_tool_use = object_value(usage, :server_tool_use)
64
+ return [] unless server_tool_use
65
+
66
+ [
67
+ line_item_for_server_tool(server_tool_use, :web_search_request, :web_search_requests,
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"),
71
+ line_item_for_server_tool(server_tool_use, :code_execution_request, :code_execution_requests,
72
+ "usage.server_tool_use.code_execution_requests")
73
+ ].compact
74
+ end
75
+
76
+ def line_item_for_server_tool(server_tool_use, component_key, count_key, provider_field)
77
+ quantity = server_tool_count(server_tool_use, count_key)
78
+ return nil if quantity.zero?
79
+
80
+ Billing::LineItem.build(
81
+ component_key: component_key,
82
+ quantity: quantity,
83
+ cost_status: Billing::CostStatus::UNKNOWN,
84
+ pricing_basis: :provider_usage,
85
+ provider_field: provider_field
86
+ )
87
+ end
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
+
62
97
  def token_usage(usage:, input_tokens:, output_tokens:)
63
- cache_write_1h = object_dig(usage, :cache_creation, :ephemeral_1h_input_tokens).to_i
64
- cache_write_5m = object_dig(usage, :cache_creation, :ephemeral_5m_input_tokens)
65
- cache_write = if cache_write_5m.nil?
66
- total_cache_write = object_value(usage, :cache_creation_input_tokens)
67
- [total_cache_write.to_i - cache_write_1h, 0].max
68
- else
69
- cache_write_5m.to_i
70
- end
98
+ cache_creation = object_value(usage, :cache_creation)
99
+ if cache_creation
100
+ cache_write_default = object_value(cache_creation, :ephemeral_5m_input_tokens).to_i
101
+ cache_write_extended = object_value(cache_creation, :ephemeral_1h_input_tokens).to_i
102
+ else
103
+ cache_write_default = object_value(usage, :cache_creation_input_tokens).to_i
104
+ cache_write_extended = 0
105
+ end
71
106
  hidden_output = (
72
107
  object_value(usage, :thinking_tokens, :thinking_output_tokens) ||
73
108
  object_dig(usage, :output_tokens_details, :reasoning_tokens)
@@ -77,57 +112,48 @@ module LlmCostTracker
77
112
  input_tokens: input_tokens.to_i,
78
113
  output_tokens: output_tokens.to_i,
79
114
  cache_read_input_tokens: object_value(usage, :cache_read_input_tokens).to_i,
80
- cache_write_input_tokens: cache_write,
81
- cache_write_1h_input_tokens: cache_write_1h,
115
+ cache_write_input_tokens: cache_write_default,
116
+ cache_write_extended_input_tokens: cache_write_extended,
82
117
  hidden_output_tokens: hidden_output
83
118
  )
84
119
  end
85
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
+
86
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
+
87
132
  modes = [
88
133
  Pricing.normalize_mode(object_value(usage, :speed) || object_value(message, :speed) || request[:speed]),
89
- Pricing.normalize_mode(
90
- object_value(usage, :service_tier) || object_value(message, :service_tier) || request[:service_tier]
91
- )
134
+ Pricing.normalize_mode(service_tier)
92
135
  ]
93
- 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)
94
138
  modes = modes.compact.uniq
95
139
  modes.empty? ? nil : modes.join("_")
96
140
  end
97
141
 
142
+ def stream_pricing_mode(request)
143
+ pricing_mode(message: nil, request: request || {}, usage: nil)
144
+ end
145
+
98
146
  def inference_geo(message:, request:, usage:)
99
147
  object_value(usage, :inference_geo) ||
100
148
  object_value(message, :inference_geo) ||
101
149
  request[:inference_geo]
102
150
  end
103
-
104
- def track_stream(stream, collector:)
105
- return stream unless active?
106
-
107
- LlmCostTracker::Capture::StreamTracker.new(
108
- stream: stream,
109
- collector: collector,
110
- active: -> { active? },
111
- finish: ->(errored:) { finish_stream(collector, errored: errored) }
112
- ).wrap
113
- end
114
-
115
- def stream_collector(request)
116
- LlmCostTracker::Capture::StreamCollector.new(
117
- provider: "anthropic",
118
- model: request[:model]
119
- )
120
- end
121
-
122
- def finish_stream(collector, errored:)
123
- record_safely { collector.finish!(errored: errored) }
124
- end
125
151
  end
126
152
 
127
153
  module MessagesPatch
128
154
  def create(*args, **kwargs)
129
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
155
  LlmCostTracker::Integrations::Anthropic.enforce_budget!
156
+ started_at = LlmCostTracker::Timing.now_monotonic
131
157
  message = super
132
158
  LlmCostTracker::Integrations::Anthropic.record_message(
133
159
  message,
@@ -139,16 +165,16 @@ module LlmCostTracker
139
165
 
140
166
  def stream(*args, **kwargs)
141
167
  request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
142
- collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
143
168
  LlmCostTracker::Integrations::Anthropic.enforce_budget!
169
+ collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
144
170
  stream = super
145
171
  LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
146
172
  end
147
173
 
148
174
  def stream_raw(*args, **kwargs)
149
175
  request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
150
- collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
151
176
  LlmCostTracker::Integrations::Anthropic.enforce_budget!
177
+ collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
152
178
  stream = super
153
179
  LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
154
180
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/hash/indifferent_access"
4
- require "active_support/core_ext/object/try"
5
4
  require "active_support/core_ext/string/inflections"
6
5
 
7
6
  require_relative "../logging"
7
+ require_relative "../timing"
8
+ require_relative "../capture/stream_collector"
9
+ require_relative "../capture/stream_tracker"
8
10
 
9
11
  module LlmCostTracker
10
12
  module Integrations
@@ -30,17 +32,16 @@ module LlmCostTracker
30
32
  return Result.new(name, :warn, "#{name} integration cannot be installed: #{problems.join('; ')}")
31
33
  end
32
34
 
33
- required_targets = patch_targets.reject { |target| target.fetch(:optional) }
34
- installed = required_targets.count do |target|
35
+ installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
35
36
  target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
36
37
  end
37
- return Result.new(name, :ok, "#{name} integration installed") if installed == required_targets.count
38
+ return Result.new(name, :ok, "#{name} integration installed") if installed
38
39
 
39
40
  Result.new(name, :warn, "#{name} integration is enabled but not installed")
40
41
  end
41
42
 
42
43
  def elapsed_ms(started_at)
43
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
44
+ Timing.elapsed_ms(started_at)
44
45
  end
45
46
 
46
47
  def enforce_budget!
@@ -56,8 +57,45 @@ module LlmCostTracker
56
57
  end
57
58
 
58
59
  def request_params(args, kwargs)
59
- 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
60
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]
75
+ end
76
+
77
+ def track_stream(stream, collector:)
78
+ return stream unless active?
79
+
80
+ LlmCostTracker::Capture::StreamTracker.new(
81
+ stream: stream,
82
+ collector: collector,
83
+ active: -> { active? },
84
+ finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
85
+ ).wrap
86
+ end
87
+
88
+ def stream_collector(request)
89
+ LlmCostTracker::Capture::StreamCollector.new(
90
+ provider: integration_name.to_s,
91
+ model: request[:model],
92
+ pricing_mode: stream_pricing_mode(request),
93
+ request: request
94
+ )
95
+ end
96
+
97
+ def stream_pricing_mode(_request)
98
+ nil
61
99
  end
62
100
 
63
101
  def object_value(object, *keys)
@@ -69,15 +107,6 @@ module LlmCostTracker
69
107
  end
70
108
 
71
109
  def object_dig(object, *path)
72
- if object.respond_to?(:dig)
73
- begin
74
- value = object.dig(*path)
75
- return value unless value.nil?
76
- rescue NameError, TypeError
77
- nil
78
- end
79
- end
80
-
81
110
  path.reduce(object) do |current, key|
82
111
  return nil if current.nil?
83
112
 
@@ -91,12 +120,13 @@ module LlmCostTracker
91
120
 
92
121
  def patch_targets = []
93
122
 
94
- def patch_target(constant_name, with:, methods:, optional: false)
123
+ def patch_target(constant_name, with:, methods:, optional: false, skip_when_methods_missing: false)
95
124
  {
96
125
  constant_name: constant_name,
97
126
  patch: with,
98
127
  method_names: Array(methods),
99
- optional: optional
128
+ optional: optional,
129
+ skip_when_methods_missing: skip_when_methods_missing
100
130
  }
101
131
  end
102
132
 
@@ -106,25 +136,17 @@ module LlmCostTracker
106
136
 
107
137
  def read_object_value(object, key)
108
138
  return nil if object.nil?
109
- return object[key] if object.try(:key?, key)
110
-
111
- string_key = key.to_s
112
- return object[string_key] if object.try(:key?, string_key)
113
139
 
114
- value = object.try(key)
115
- return value unless value.nil?
140
+ if object.is_a?(Hash)
141
+ return object[key] if object.key?(key)
142
+ return object[key.name] if key.is_a?(Symbol) && object.key?(key.name)
143
+ end
116
144
 
117
- indexed_object_value(object, key)
145
+ object.public_send(key) if object.respond_to?(key)
118
146
  end
119
147
 
120
- def indexed_object_value(object, key)
121
- object.try(:[], key)
122
- rescue IndexError, NameError, TypeError
123
- nil
124
- end
125
-
126
- module_function :read_object_value, :indexed_object_value
127
- private_class_method :read_object_value, :indexed_object_value
148
+ module_function :read_object_value
149
+ private_class_method :read_object_value
128
150
 
129
151
  def validate_contract!
130
152
  problems = version_problems + target_problems
@@ -165,6 +187,8 @@ module LlmCostTracker
165
187
  end
166
188
 
167
189
  def missing_methods(target_class, target)
190
+ return [] if target[:skip_when_methods_missing]
191
+
168
192
  target.fetch(:method_names).filter_map do |method_name|
169
193
  next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
170
194