llm_cost_tracker 0.11.0 → 0.13.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -0
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +12 -19
  10. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  11. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  12. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  13. data/app/models/llm_cost_tracker/call.rb +28 -63
  14. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  15. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  16. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  17. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +3 -0
  18. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +76 -43
  19. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  20. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  21. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  22. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  23. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  24. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  26. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  27. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  28. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  29. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  30. data/app/views/llm_cost_tracker/calls/show.html.erb +33 -25
  31. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  32. data/app/views/llm_cost_tracker/data_quality/index.html.erb +73 -117
  33. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  34. data/app/views/llm_cost_tracker/pricing/index.html.erb +3 -3
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  38. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  39. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  40. data/config/routes.rb +2 -3
  41. data/lib/llm_cost_tracker/budget.rb +24 -26
  42. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  43. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +32 -39
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  46. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  47. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  48. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  49. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +41 -44
  50. data/lib/llm_cost_tracker/check.rb +5 -0
  51. data/lib/llm_cost_tracker/configuration.rb +13 -44
  52. data/lib/llm_cost_tracker/currency.rb +5 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  54. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  55. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  56. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  57. data/lib/llm_cost_tracker/doctor.rb +5 -69
  58. data/lib/llm_cost_tracker/engine.rb +4 -4
  59. data/lib/llm_cost_tracker/event.rb +12 -18
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +4 -3
  64. data/lib/llm_cost_tracker/ingestion/batch.rb +61 -12
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  66. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  68. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +93 -106
  70. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  71. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  72. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  73. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  74. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  75. data/lib/llm_cost_tracker/integrations.rb +32 -25
  76. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  77. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  80. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  82. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  83. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  84. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  86. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  87. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  88. data/lib/llm_cost_tracker/ledger/store.rb +24 -41
  89. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  90. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  91. data/lib/llm_cost_tracker/ledger.rb +8 -18
  92. data/lib/llm_cost_tracker/logging.rb +4 -21
  93. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  94. data/lib/llm_cost_tracker/parsers.rb +139 -26
  95. data/lib/llm_cost_tracker/prices.json +1821 -13
  96. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  97. data/lib/llm_cost_tracker/pricing/calculation.rb +279 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  99. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  100. data/lib/llm_cost_tracker/pricing/matcher.rb +98 -0
  101. data/lib/llm_cost_tracker/pricing/mode.rb +44 -56
  102. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  103. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  104. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  105. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  106. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  107. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  108. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  109. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  110. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  111. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  112. data/lib/llm_cost_tracker/pricing.rb +10 -278
  113. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  117. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  118. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  119. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  120. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  121. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  122. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  123. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  124. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  125. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  126. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  127. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  128. data/lib/llm_cost_tracker/providers.rb +35 -0
  129. data/lib/llm_cost_tracker/railtie.rb +0 -3
  130. data/lib/llm_cost_tracker/report/data.rb +3 -4
  131. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  132. data/lib/llm_cost_tracker/report.rb +1 -1
  133. data/lib/llm_cost_tracker/retention.rb +6 -19
  134. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  135. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  136. data/lib/llm_cost_tracker/timing.rb +2 -4
  137. data/lib/llm_cost_tracker/tracker.rb +23 -36
  138. data/lib/llm_cost_tracker/usage/catalog.rb +69 -0
  139. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  140. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +6 -52
  141. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  142. data/lib/llm_cost_tracker/usage/token_usage.rb +45 -0
  143. data/lib/llm_cost_tracker/version.rb +1 -1
  144. data/lib/llm_cost_tracker.rb +43 -52
  145. data/lib/tasks/llm_cost_tracker.rake +14 -73
  146. metadata +81 -55
  147. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  148. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  149. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  151. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  152. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  153. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  154. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  155. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  156. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  157. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  158. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  159. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  160. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  161. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  168. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  170. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  172. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  173. data/lib/llm_cost_tracker/masking.rb +0 -39
  174. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  175. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  176. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  177. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  178. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  179. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  180. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  181. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  182. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  183. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  184. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  185. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  186. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  187. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  188. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  189. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  190. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  191. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  192. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  193. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  194. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  195. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  196. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module Ingestion
9
+ module Leases
10
+ extend Base
11
+
12
+ columns :name, :locked_by, :locked_until, :created_at, :updated_at
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema/adapter"
4
+ require_relative "schema/calls"
5
+ require_relative "schema/call_rollups"
6
+ require_relative "schema/call_line_items"
7
+ require_relative "schema/call_tags"
8
+ require_relative "schema/ingestion/inbox_entries"
9
+ require_relative "schema/ingestion/leases"
10
+
11
+ module LlmCostTracker
12
+ module Ledger
13
+ module Schema
14
+ CORE_SCHEMAS = [
15
+ [Calls, "llm_cost_tracker_calls"],
16
+ [CallLineItems, "llm_cost_tracker_call_line_items"],
17
+ [CallTags, "llm_cost_tracker_call_tags"]
18
+ ].freeze
19
+ CACHE_ROLLUPS_SCHEMA = [CallRollups, "llm_cost_tracker_call_rollups"].freeze
20
+ ASYNC_SCHEMAS = [
21
+ [Ingestion::InboxEntries, "llm_cost_tracker_ingestion_inbox_entries"],
22
+ [Ingestion::Leases, "llm_cost_tracker_ingestion_leases"]
23
+ ].freeze
24
+ end
25
+ end
26
+ end
@@ -3,28 +3,31 @@
3
3
  require "json"
4
4
 
5
5
  require_relative "../pricing"
6
- require_relative "../billing/line_item"
7
6
  require_relative "rollups"
8
7
  require_relative "tags/encoding"
9
8
 
10
9
  module LlmCostTracker
11
10
  module Ledger
12
- class Store
11
+ module Store
13
12
  class << self
14
- def insert(events, skip_existence_check: false)
13
+ def insert(events)
15
14
  events = Array(events)
16
15
  return if events.empty?
17
16
 
18
- insertable = skip_existence_check ? events : insertable_events(events)
19
- return unless insertable.any?
17
+ persist_records(events)
18
+ Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
19
+ end
20
+
21
+ def persist_records(events)
22
+ events = Array(events)
23
+ return if events.empty?
20
24
 
21
25
  LlmCostTracker::Call.transaction do
22
- rows = insertable.map { |event| attributes_for(event) }
23
- call_ids = insert_calls_returning_ids(rows, insertable)
24
- insert_line_items(insertable, call_ids)
25
- insert_call_tags(insertable, call_ids)
26
+ rows = events.map { |event| attributes_for(event) }
27
+ call_ids = insert_calls_returning_ids(rows, events)
28
+ insert_line_items(events, call_ids)
29
+ insert_call_tags(events, call_ids)
26
30
  end
27
- increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
28
31
  end
29
32
 
30
33
  private
@@ -45,22 +48,22 @@ module LlmCostTracker
45
48
  provider: event.provider,
46
49
  model: event.model,
47
50
  tracked_at: event.tracked_at,
48
- pricing_mode: event.pricing_mode&.name,
51
+ pricing_mode: event.pricing_mode,
49
52
  latency_ms: event.latency_ms,
50
53
  stream: event.stream,
51
- usage_source: event.usage_source&.name,
54
+ usage_source: event.usage_source,
52
55
  provider_response_id: event.provider_response_id,
53
56
  provider_project_id: event.provider_project_id,
54
57
  provider_api_key_id: event.provider_api_key_id,
55
58
  provider_workspace_id: event.provider_workspace_id,
56
- batch: event.batch,
59
+ batch: event.batch?,
57
60
  cost_status: event.cost_status,
58
61
  pricing_snapshot: event.pricing_snapshot
59
62
  }
60
63
 
61
64
  attributes
62
65
  .merge(event.token_usage.to_h)
63
- .merge(Pricing.stored_cost_attributes(event.cost || {}))
66
+ .merge(total_cost: event.cost&.total)
64
67
  end
65
68
 
66
69
  def call_ids_for(events)
@@ -89,20 +92,20 @@ module LlmCostTracker
89
92
  {
90
93
  llm_cost_tracker_call_id: call_id,
91
94
  position: position,
92
- kind: line_item.kind&.to_s,
93
- direction: line_item.direction&.to_s,
94
- modality: line_item.modality&.to_s,
95
- cache_state: line_item.cache_state&.to_s || "none",
95
+ kind: line_item.kind,
96
+ direction: line_item.direction,
97
+ modality: line_item.modality,
98
+ cache_state: line_item.cache_state,
96
99
  quantity: line_item.quantity,
97
- unit: line_item.unit&.to_s,
100
+ unit: line_item.unit,
98
101
  rate_amount: line_item.rate_amount,
99
102
  rate_quantity: line_item.rate_quantity,
100
103
  cost: line_item.cost,
101
104
  currency: line_item.currency,
102
105
  cost_status: line_item.cost_status,
103
- pricing_basis: line_item.pricing_basis&.to_s,
106
+ pricing_basis: line_item.pricing_basis,
104
107
  price_key: line_item.price_key,
105
- price_source: line_item.price_source&.to_s,
108
+ price_source: line_item.price_source,
106
109
  price_source_version: line_item.price_source_version,
107
110
  provider_field: line_item.provider_field,
108
111
  provider_item_id: line_item.provider_item_id,
@@ -129,26 +132,6 @@ module LlmCostTracker
129
132
  def stored_details(details)
130
133
  (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
131
134
  end
132
-
133
- def increment_rollups_safely(events)
134
- Ledger::Rollups.increment_many!(events)
135
- rescue StandardError => e
136
- raise if LlmCostTracker::Call.connection.open_transactions.positive?
137
-
138
- LlmCostTracker::Logging.warn(
139
- "Rollup increment failed for #{events.size} events after ledger commit: #{e.class}: #{e.message}"
140
- )
141
- end
142
-
143
- def insertable_events(events)
144
- existing_ids = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
145
- seen_ids = Set.new
146
-
147
- events.select do |event|
148
- event_id = event.event_id
149
- !existing_ids.include?(event_id) && seen_ids.add?(event_id)
150
- end
151
- end
152
135
  end
153
136
  end
154
137
  end
@@ -5,7 +5,7 @@ require_relative "../../tags/key"
5
5
  module LlmCostTracker
6
6
  module Ledger
7
7
  module Tags
8
- module Sql
8
+ module Breakdown
9
9
  UNTAGGED_LABEL = "(untagged)"
10
10
 
11
11
  class << self
@@ -6,9 +6,7 @@ module LlmCostTracker
6
6
  module Ledger
7
7
  module Tags
8
8
  module Encoding
9
- module_function
10
-
11
- def encode(value)
9
+ def self.encode(value)
12
10
  case value
13
11
  when Hash then JSON.generate(normalize_hash(value))
14
12
  when Array then JSON.generate(normalize_array(value))
@@ -16,15 +14,15 @@ module LlmCostTracker
16
14
  end
17
15
  end
18
16
 
19
- def normalize_hash(hash)
17
+ def self.normalize_hash(hash)
20
18
  hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
21
19
  end
22
20
 
23
- def normalize_array(array)
21
+ def self.normalize_array(array)
24
22
  array.map { |v| normalize_value(v) }
25
23
  end
26
24
 
27
- def normalize_value(value)
25
+ def self.normalize_value(value)
28
26
  case value
29
27
  when Hash then normalize_hash(value)
30
28
  when Array then normalize_array(value)
@@ -1,29 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "ledger/schema/adapter"
4
- require_relative "ledger/schema/calls"
5
- require_relative "ledger/schema/call_rollups"
6
- require_relative "ledger/schema/call_line_items"
7
- require_relative "ledger/schema/call_tags"
8
- require_relative "ledger/schema/ingestion_inbox_entries"
9
- require_relative "ledger/schema/ingestion_leases"
10
- require_relative "ledger/tags/query"
11
- require_relative "ledger/tags/sql"
3
+ require_relative "ledger/schema"
12
4
  require_relative "ledger/period"
13
- require_relative "ledger/rollups/upsert_sql"
14
5
  require_relative "ledger/rollups"
15
6
  require_relative "ledger/store"
16
- require_relative "ledger/period/totals"
17
7
 
18
8
  module LlmCostTracker
19
9
  module Ledger
20
- module Schema
21
- CORE_SCHEMAS = [
22
- [Calls, "llm_cost_tracker_calls"],
23
- [CallLineItems, "llm_cost_tracker_call_line_items"],
24
- [CallTags, "llm_cost_tracker_call_tags"]
25
- ].freeze
26
- CACHE_ROLLUPS_SCHEMA = [CallRollups, "llm_cost_tracker_call_rollups"].freeze
10
+ module Tags
11
+ autoload :Query, "llm_cost_tracker/ledger/tags/query"
12
+ autoload :Breakdown, "llm_cost_tracker/ledger/tags/breakdown"
13
+ end
14
+
15
+ module Period
16
+ autoload :Totals, "llm_cost_tracker/ledger/period/totals"
27
17
  end
28
18
  end
29
19
  end
@@ -2,32 +2,15 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Logging
5
- PREFIX = "[LlmCostTracker]"
6
-
7
5
  class << self
8
- def debug(message)
9
- log(:debug, message)
10
- end
11
-
12
- def warn(message)
13
- log(:warn, message)
14
- end
6
+ def debug(message) = tagged.debug(message)
15
7
 
16
- def log(level, message)
17
- message = prefixed(message)
18
- logger = Rails.logger
19
- return Kernel.warn(message) unless logger
20
-
21
- logger.public_send(level, message)
22
- end
8
+ def warn(message) = tagged.warn(message)
23
9
 
24
10
  private
25
11
 
26
- def prefixed(message)
27
- message = message.to_s
28
- return message if message.start_with?(PREFIX)
29
-
30
- "#{PREFIX} #{message}"
12
+ def tagged
13
+ Rails.logger.tagged(LlmCostTracker.name)
31
14
  end
32
15
  end
33
16
  end
@@ -5,9 +5,7 @@ require "json"
5
5
  require "stringio"
6
6
  require "uri"
7
7
 
8
- require_relative "../logging"
9
8
  require_relative "../capture/sse"
10
- require_relative "../capture/stream"
11
9
  require_relative "../timing"
12
10
 
13
11
  module LlmCostTracker
@@ -19,12 +17,12 @@ module LlmCostTracker
19
17
  end
20
18
 
21
19
  def call(request_env)
22
- return @app.call(request_env) unless enabled?
20
+ return @app.call(request_env) unless LlmCostTracker.configuration.enabled
23
21
 
24
22
  request_url = request_env.url.to_s
25
23
  request_body = read_body(request_env.body)
26
24
  parser = Parsers.find_for(request_url)
27
- request_parsed = parser ? safe_json_parse(request_body) : nil
25
+ request_parsed = parser&.safe_json_parse(request_body)
28
26
  streaming = parser&.streaming_request?(request_url, request_parsed)
29
27
  if streaming
30
28
  request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
@@ -32,7 +30,7 @@ module LlmCostTracker
32
30
  stream_buffer = install_stream_tap(request_env) if streaming
33
31
 
34
32
  if parser
35
- Tracker.enforce_budget!(
33
+ Budget.enforce!(
36
34
  provider: parser.provider_for(request_url),
37
35
  model: parser.model_for(request_url, request_parsed),
38
36
  request: request_parsed
@@ -42,59 +40,61 @@ module LlmCostTracker
42
40
  started_at = LlmCostTracker::Timing.now_monotonic
43
41
 
44
42
  invoke_app_with_capture(
45
- request_env: request_env, parser: parser, request_url: request_url,
46
- request_body: request_body, streaming: streaming, stream_buffer: stream_buffer,
47
- context_tags: context_tags, metadata: metadata, started_at: started_at
43
+ request_env: request_env,
44
+ parser: parser,
45
+ request_url: request_url,
46
+ request_body: request_body,
47
+ streaming: streaming,
48
+ stream_buffer: stream_buffer,
49
+ context_tags: context_tags,
50
+ metadata: metadata,
51
+ started_at: started_at
48
52
  )
49
53
  end
50
54
 
51
55
  private
52
56
 
53
- def enabled?
54
- return @enabled if defined?(@enabled)
55
-
56
- @enabled = LlmCostTracker.configuration.enabled
57
- end
58
-
59
- def safe_json_parse(body)
60
- return {} if body.nil? || body.empty?
61
-
62
- JSON.parse(body)
63
- rescue JSON::ParserError
64
- {}
65
- end
66
-
67
- def auto_enable_stream_usage?
68
- return @auto_enable_stream_usage if defined?(@auto_enable_stream_usage)
69
-
70
- @auto_enable_stream_usage = LlmCostTracker.configuration.auto_enable_stream_usage
71
- end
72
-
73
- def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
74
- stream_buffer:, context_tags:, metadata:, started_at:)
57
+ def invoke_app_with_capture(request_env:,
58
+ parser:,
59
+ request_url:,
60
+ request_body:,
61
+ streaming:,
62
+ stream_buffer:,
63
+ context_tags:,
64
+ metadata:,
65
+ started_at:)
75
66
  response_received = false
76
67
  @app.call(request_env).on_complete do |response_env|
77
68
  response_received = true
78
69
  process(
79
- parser: parser, request_url: request_url, request_body: request_body,
80
- response_env: response_env, latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
81
- streaming: streaming, stream_buffer: stream_buffer,
82
- context_tags: context_tags, metadata: metadata
70
+ parser: parser,
71
+ request_url: request_url,
72
+ request_body: request_body,
73
+ response_env: response_env,
74
+ latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
75
+ streaming: streaming,
76
+ stream_buffer: stream_buffer,
77
+ context_tags: context_tags,
78
+ metadata: metadata
83
79
  )
84
80
  end
85
81
  rescue StandardError => e
86
82
  if streaming && parser && !response_received
87
83
  process_interrupted_stream(
88
- parser: parser, request_url: request_url, request_body: request_body,
84
+ parser: parser,
85
+ request_url: request_url,
86
+ request_body: request_body,
89
87
  latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
90
- context_tags: context_tags, metadata: metadata, error: e
88
+ context_tags: context_tags,
89
+ metadata: metadata,
90
+ error: e
91
91
  )
92
92
  end
93
93
  raise
94
94
  end
95
95
 
96
96
  def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
97
- return nil unless auto_enable_stream_usage?
97
+ return nil unless LlmCostTracker.configuration.auto_enable_stream_usage
98
98
  return nil unless parser&.auto_enable_stream_usage?(request_url)
99
99
 
100
100
  stream_options = request_parsed["stream_options"]
@@ -106,15 +106,20 @@ module LlmCostTracker
106
106
  new_body
107
107
  end
108
108
 
109
- def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
110
- context_tags:, metadata:, error:)
109
+ def process_interrupted_stream(parser:,
110
+ request_url:,
111
+ request_body:,
112
+ latency_ms:,
113
+ context_tags:,
114
+ metadata:,
115
+ error:)
111
116
  request = parser.safe_json_parse(request_body)
112
117
  event = Event.build(
113
118
  provider: parser.provider_for(request_url),
114
119
  model: request["model"] || Event::UNKNOWN_MODEL,
115
- token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
120
+ token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
116
121
  stream: true,
117
- usage_source: :unknown
122
+ usage_source: Usage::Source::UNKNOWN
118
123
  )
119
124
  merged_metadata = (metadata || {}).merge(
120
125
  stream_interrupted: true,
@@ -130,8 +135,15 @@ module LlmCostTracker
130
135
  Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
131
136
  end
132
137
 
133
- def process(parser:, request_url:, request_body:, response_env:,
134
- latency_ms:, streaming:, stream_buffer:, context_tags:, metadata:)
138
+ def process(parser:,
139
+ request_url:,
140
+ request_body:,
141
+ response_env:,
142
+ latency_ms:,
143
+ streaming:,
144
+ stream_buffer:,
145
+ context_tags:,
146
+ metadata:)
135
147
  return unless parser
136
148
 
137
149
  parsed =
@@ -233,7 +245,7 @@ module LlmCostTracker
233
245
  state = { buffer: StringIO.new, bytes: 0, overflowed: false }
234
246
  request.on_data = proc do |chunk, size, env|
235
247
  chunk = chunk.to_s
236
- remaining = Capture::Stream::LIMIT_BYTES - state[:bytes]
248
+ remaining = Capture::SSE::LIMIT_BYTES - state[:bytes]
237
249
  if chunk.bytesize <= remaining
238
250
  state[:buffer] << chunk
239
251
  state[:bytes] += chunk.bytesize
@@ -280,13 +292,12 @@ module LlmCostTracker
280
292
  end
281
293
 
282
294
  def capture_warning(request_url, stream_buffer)
283
- unless stream_buffer&.dig(:overflowed)
284
- return "Unable to capture streaming response for #{request_url_label(request_url)}; " \
285
- "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
286
- end
295
+ suffix = "recording usage_source=#{Usage::Source::UNKNOWN}. " \
296
+ "Use LlmCostTracker.track_stream for manual capture."
297
+ label = request_url_label(request_url)
298
+ return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_buffer&.dig(:overflowed)
287
299
 
288
- "Streaming response for #{request_url_label(request_url)} exceeded #{Capture::Stream::LIMIT_BYTES} bytes; " \
289
- "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
300
+ "Streaming response for #{label} exceeded #{Capture::SSE::LIMIT_BYTES} bytes; #{suffix}"
290
301
  end
291
302
 
292
303
  def request_url_label(value)
@@ -1,45 +1,158 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LlmCostTracker
4
- module Parsers
5
- autoload :Base, "llm_cost_tracker/parsers/base"
6
- autoload :OpenaiUsage, "llm_cost_tracker/parsers/openai_usage"
7
- autoload :Openai, "llm_cost_tracker/parsers/openai"
8
- autoload :Azure, "llm_cost_tracker/parsers/azure"
9
- autoload :OpenaiCompatible, "llm_cost_tracker/parsers/openai_compatible"
10
- autoload :Anthropic, "llm_cost_tracker/parsers/anthropic"
11
- autoload :Gemini, "llm_cost_tracker/parsers/gemini"
3
+ require "active_support/core_ext/object/blank"
4
+ require "json"
5
+ require "uri"
12
6
 
13
- MUTEX = Mutex.new
14
- PARSER_CONSTANTS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
7
+ require_relative "providers"
15
8
 
16
- module_function
9
+ module LlmCostTracker
10
+ module Parsers
11
+ PARSER_PROVIDERS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
17
12
 
18
- def find_for(url)
19
- PARSER_CONSTANTS.each do |name|
20
- klass = const_get(name)
21
- return instance_for(klass) if klass.match?(url)
13
+ def self.find_for(url)
14
+ instances.each do |klass, instance|
15
+ return instance if klass.match?(url)
22
16
  end
23
17
  nil
24
18
  end
25
19
 
26
- def find_for_provider(provider)
20
+ def self.find_for_provider(provider)
27
21
  provider_name = provider.to_s.downcase
28
- PARSER_CONSTANTS.each do |name|
29
- klass = const_get(name)
30
- return instance_for(klass) if klass.provider_names.include?(provider_name)
22
+ instances.each do |klass, instance|
23
+ return instance if klass.provider_names.include?(provider_name)
31
24
  end
32
25
  nil
33
26
  end
34
27
 
35
- def instance_for(klass)
36
- cached = (@instances ||= {})[klass]
37
- return cached if cached
28
+ def self.parser_classes
29
+ PARSER_PROVIDERS.map { |name| Providers.const_get(name)::Parser }
30
+ end
31
+ private_class_method :parser_classes
32
+
33
+ def self.instances
34
+ @instances ||= parser_classes.to_h { |klass| [klass, klass.new] }.freeze
35
+ end
36
+ private_class_method :instances
37
+
38
+ module UrlMatchers
39
+ def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
40
+ uri_matches?(url) do |uri|
41
+ host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
42
+ path_match = path_matches?(
43
+ uri,
44
+ exact_paths: exact_paths,
45
+ path_includes: path_includes,
46
+ path_suffixes: path_suffixes,
47
+ path_pattern: path_pattern
48
+ )
49
+ extra_match = block_given? ? yield(uri) : true
50
+
51
+ !!(host_match && path_match && extra_match)
52
+ end
53
+ end
54
+
55
+ def uri_matches?(url)
56
+ uri = parsed_uri(url)
57
+ uri ? yield(uri) : false
58
+ end
59
+
60
+ def parsed_uri(url)
61
+ URI.parse(url.to_s)
62
+ rescue URI::InvalidURIError
63
+ nil
64
+ end
65
+
66
+ def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
67
+ path = uri.path.to_s
68
+ matches = true
69
+ matches &&= exact_paths.include?(path) if exact_paths
70
+ matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
71
+ matches &&= path.match?(path_pattern) if path_pattern
72
+ matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
73
+ matches
74
+ end
75
+ end
76
+
77
+ class Base
78
+ extend UrlMatchers
79
+ include UrlMatchers
80
+
81
+ class << self
82
+ def match?(_url)
83
+ raise NotImplementedError
84
+ end
85
+
86
+ def provider_names
87
+ []
88
+ end
89
+ end
90
+
91
+ def parse(**)
92
+ raise NotImplementedError
93
+ end
94
+
95
+ def streaming_request?(_request_url, request_parsed)
96
+ request_parsed["stream"] == true
97
+ end
98
+
99
+ def model_for(_request_url, request_parsed)
100
+ request_parsed["model"]
101
+ end
102
+
103
+ def parse_stream(**)
104
+ nil
105
+ end
106
+
107
+ def auto_enable_stream_usage?(_request_url)
108
+ false
109
+ end
110
+
111
+ def safe_json_parse(body)
112
+ return {} if body.blank?
113
+
114
+ parsed = JSON.parse(body)
115
+ parsed.is_a?(Hash) ? parsed : {}
116
+ rescue JSON::ParserError
117
+ {}
118
+ end
119
+
120
+ private
121
+
122
+ def each_event_data(events, reverse: false)
123
+ enumerator = reverse ? events.reverse_each : events.each
124
+
125
+ enumerator.each do |event|
126
+ data = event[:data]
127
+ yield data if data.is_a?(Hash)
128
+ end
129
+ end
130
+
131
+ def find_event_value(events, reverse: false)
132
+ each_event_data(events, reverse:) do |data|
133
+ value = yield(data)
134
+ return value if value.present?
135
+ end
136
+
137
+ nil
138
+ end
38
139
 
39
- MUTEX.synchronize do
40
- @instances[klass] ||= klass.new
140
+ def build_unknown_stream_usage(provider:,
141
+ model:,
142
+ provider_response_id:,
143
+ pricing_mode: nil,
144
+ service_line_items: nil)
145
+ Event.build(
146
+ provider: provider,
147
+ provider_response_id: provider_response_id,
148
+ pricing_mode: pricing_mode,
149
+ model: model || Event::UNKNOWN_MODEL,
150
+ token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
151
+ stream: true,
152
+ usage_source: Usage::Source::UNKNOWN,
153
+ service_line_items: service_line_items
154
+ )
41
155
  end
42
156
  end
43
- private_class_method :instance_for
44
157
  end
45
158
  end