llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. 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,24 @@
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?
20
-
21
17
  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)
18
+ rows = events.map { |event| attributes_for(event) }
19
+ call_ids = insert_calls_returning_ids(rows, events)
20
+ insert_line_items(events, call_ids)
21
+ insert_call_tags(events, call_ids)
26
22
  end
27
- increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
23
+ Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
28
24
  end
29
25
 
30
26
  private
@@ -45,22 +41,22 @@ module LlmCostTracker
45
41
  provider: event.provider,
46
42
  model: event.model,
47
43
  tracked_at: event.tracked_at,
48
- pricing_mode: event.pricing_mode&.name,
44
+ pricing_mode: event.pricing_mode,
49
45
  latency_ms: event.latency_ms,
50
46
  stream: event.stream,
51
- usage_source: event.usage_source&.name,
47
+ usage_source: event.usage_source,
52
48
  provider_response_id: event.provider_response_id,
53
49
  provider_project_id: event.provider_project_id,
54
50
  provider_api_key_id: event.provider_api_key_id,
55
51
  provider_workspace_id: event.provider_workspace_id,
56
- batch: event.batch,
52
+ batch: event.batch?,
57
53
  cost_status: event.cost_status,
58
54
  pricing_snapshot: event.pricing_snapshot
59
55
  }
60
56
 
61
57
  attributes
62
58
  .merge(event.token_usage.to_h)
63
- .merge(Pricing.stored_cost_attributes(event.cost || {}))
59
+ .merge(total_cost: event.cost&.total)
64
60
  end
65
61
 
66
62
  def call_ids_for(events)
@@ -89,20 +85,20 @@ module LlmCostTracker
89
85
  {
90
86
  llm_cost_tracker_call_id: call_id,
91
87
  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",
88
+ kind: line_item.kind,
89
+ direction: line_item.direction,
90
+ modality: line_item.modality,
91
+ cache_state: line_item.cache_state,
96
92
  quantity: line_item.quantity,
97
- unit: line_item.unit&.to_s,
93
+ unit: line_item.unit,
98
94
  rate_amount: line_item.rate_amount,
99
95
  rate_quantity: line_item.rate_quantity,
100
96
  cost: line_item.cost,
101
97
  currency: line_item.currency,
102
98
  cost_status: line_item.cost_status,
103
- pricing_basis: line_item.pricing_basis&.to_s,
99
+ pricing_basis: line_item.pricing_basis,
104
100
  price_key: line_item.price_key,
105
- price_source: line_item.price_source&.to_s,
101
+ price_source: line_item.price_source,
106
102
  price_source_version: line_item.price_source_version,
107
103
  provider_field: line_item.provider_field,
108
104
  provider_item_id: line_item.provider_item_id,
@@ -129,26 +125,6 @@ module LlmCostTracker
129
125
  def stored_details(details)
130
126
  (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
131
127
  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
128
  end
153
129
  end
154
130
  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,16 +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"
7
+
8
+ module LlmCostTracker
9
+ module Ledger
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"
17
+ end
18
+ end
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,8 +5,7 @@ require "json"
5
5
  require "stringio"
6
6
  require "uri"
7
7
 
8
- require_relative "../logging"
9
- require_relative "../capture/stream"
8
+ require_relative "../capture/sse"
10
9
  require_relative "../timing"
11
10
 
12
11
  module LlmCostTracker
@@ -18,12 +17,12 @@ module LlmCostTracker
18
17
  end
19
18
 
20
19
  def call(request_env)
21
- return @app.call(request_env) unless enabled?
20
+ return @app.call(request_env) unless LlmCostTracker.configuration.enabled
22
21
 
23
22
  request_url = request_env.url.to_s
24
23
  request_body = read_body(request_env.body)
25
24
  parser = Parsers.find_for(request_url)
26
- request_parsed = parser ? safe_json_parse(request_body) : nil
25
+ request_parsed = parser&.safe_json_parse(request_body)
27
26
  streaming = parser&.streaming_request?(request_url, request_parsed)
28
27
  if streaming
29
28
  request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
@@ -31,7 +30,7 @@ module LlmCostTracker
31
30
  stream_buffer = install_stream_tap(request_env) if streaming
32
31
 
33
32
  if parser
34
- Tracker.enforce_budget!(
33
+ Budget.enforce!(
35
34
  provider: parser.provider_for(request_url),
36
35
  model: parser.model_for(request_url, request_parsed),
37
36
  request: request_parsed
@@ -41,59 +40,61 @@ module LlmCostTracker
41
40
  started_at = LlmCostTracker::Timing.now_monotonic
42
41
 
43
42
  invoke_app_with_capture(
44
- request_env: request_env, parser: parser, request_url: request_url,
45
- request_body: request_body, streaming: streaming, stream_buffer: stream_buffer,
46
- 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
47
52
  )
48
53
  end
49
54
 
50
55
  private
51
56
 
52
- def enabled?
53
- return @enabled if defined?(@enabled)
54
-
55
- @enabled = LlmCostTracker.configuration.enabled
56
- end
57
-
58
- def safe_json_parse(body)
59
- return {} if body.nil? || body.empty?
60
-
61
- JSON.parse(body)
62
- rescue JSON::ParserError
63
- {}
64
- end
65
-
66
- def auto_enable_stream_usage?
67
- return @auto_enable_stream_usage if defined?(@auto_enable_stream_usage)
68
-
69
- @auto_enable_stream_usage = LlmCostTracker.configuration.auto_enable_stream_usage
70
- end
71
-
72
- def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
73
- 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:)
74
66
  response_received = false
75
67
  @app.call(request_env).on_complete do |response_env|
76
68
  response_received = true
77
69
  process(
78
- parser: parser, request_url: request_url, request_body: request_body,
79
- response_env: response_env, latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
80
- streaming: streaming, stream_buffer: stream_buffer,
81
- 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
82
79
  )
83
80
  end
84
81
  rescue StandardError => e
85
82
  if streaming && parser && !response_received
86
83
  process_interrupted_stream(
87
- parser: parser, request_url: request_url, request_body: request_body,
84
+ parser: parser,
85
+ request_url: request_url,
86
+ request_body: request_body,
88
87
  latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
89
- context_tags: context_tags, metadata: metadata, error: e
88
+ context_tags: context_tags,
89
+ metadata: metadata,
90
+ error: e
90
91
  )
91
92
  end
92
93
  raise
93
94
  end
94
95
 
95
96
  def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
96
- return nil unless auto_enable_stream_usage?
97
+ return nil unless LlmCostTracker.configuration.auto_enable_stream_usage
97
98
  return nil unless parser&.auto_enable_stream_usage?(request_url)
98
99
 
99
100
  stream_options = request_parsed["stream_options"]
@@ -105,15 +106,20 @@ module LlmCostTracker
105
106
  new_body
106
107
  end
107
108
 
108
- def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
109
- 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:)
110
116
  request = parser.safe_json_parse(request_body)
111
117
  event = Event.build(
112
118
  provider: parser.provider_for(request_url),
113
119
  model: request["model"] || Event::UNKNOWN_MODEL,
114
- 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),
115
121
  stream: true,
116
- usage_source: :unknown
122
+ usage_source: Usage::Source::UNKNOWN
117
123
  )
118
124
  merged_metadata = (metadata || {}).merge(
119
125
  stream_interrupted: true,
@@ -129,8 +135,15 @@ module LlmCostTracker
129
135
  Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
130
136
  end
131
137
 
132
- def process(parser:, request_url:, request_body:, response_env:,
133
- 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:)
134
147
  return unless parser
135
148
 
136
149
  parsed =
@@ -201,7 +214,7 @@ module LlmCostTracker
201
214
  )
202
215
  end
203
216
 
204
- events = overflowed ? [] : Parsers::SSE.parse(body)
217
+ events = overflowed ? [] : Capture::SSE.parse(body)
205
218
  parser.parse_stream(
206
219
  request_url: request_url,
207
220
  request_body: request_body,
@@ -232,7 +245,7 @@ module LlmCostTracker
232
245
  state = { buffer: StringIO.new, bytes: 0, overflowed: false }
233
246
  request.on_data = proc do |chunk, size, env|
234
247
  chunk = chunk.to_s
235
- remaining = Capture::Stream::LIMIT_BYTES - state[:bytes]
248
+ remaining = Capture::SSE::LIMIT_BYTES - state[:bytes]
236
249
  if chunk.bytesize <= remaining
237
250
  state[:buffer] << chunk
238
251
  state[:bytes] += chunk.bytesize
@@ -279,13 +292,12 @@ module LlmCostTracker
279
292
  end
280
293
 
281
294
  def capture_warning(request_url, stream_buffer)
282
- unless stream_buffer&.dig(:overflowed)
283
- return "Unable to capture streaming response for #{request_url_label(request_url)}; " \
284
- "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
285
- 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)
286
299
 
287
- "Streaming response for #{request_url_label(request_url)} exceeded #{Capture::Stream::LIMIT_BYTES} bytes; " \
288
- "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
300
+ "Streaming response for #{label} exceeded #{Capture::SSE::LIMIT_BYTES} bytes; #{suffix}"
289
301
  end
290
302
 
291
303
  def request_url_label(value)
@@ -1,47 +1,158 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/object/blank"
4
+ require "json"
5
+ require "uri"
6
+
7
+ require_relative "providers"
8
+
3
9
  module LlmCostTracker
4
10
  module Parsers
5
- autoload :Base, "llm_cost_tracker/parsers/base"
6
- autoload :OpenaiUsage, "llm_cost_tracker/parsers/openai_usage"
7
- autoload :OpenaiServiceCharges, "llm_cost_tracker/parsers/openai_service_charges"
8
- autoload :SSE, "llm_cost_tracker/parsers/sse"
9
- autoload :Openai, "llm_cost_tracker/parsers/openai"
10
- autoload :Azure, "llm_cost_tracker/parsers/azure"
11
- autoload :OpenaiCompatible, "llm_cost_tracker/parsers/openai_compatible"
12
- autoload :Anthropic, "llm_cost_tracker/parsers/anthropic"
13
- autoload :Gemini, "llm_cost_tracker/parsers/gemini"
14
-
15
- MUTEX = Mutex.new
16
- PARSER_CONSTANTS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
17
-
18
- module_function
19
-
20
- def find_for(url)
21
- PARSER_CONSTANTS.each do |name|
22
- klass = const_get(name)
23
- return instance_for(klass) if klass.match?(url)
11
+ PARSER_PROVIDERS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
12
+
13
+ def self.find_for(url)
14
+ instances.each do |klass, instance|
15
+ return instance if klass.match?(url)
24
16
  end
25
17
  nil
26
18
  end
27
19
 
28
- def find_for_provider(provider)
20
+ def self.find_for_provider(provider)
29
21
  provider_name = provider.to_s.downcase
30
- PARSER_CONSTANTS.each do |name|
31
- klass = const_get(name)
32
- 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)
33
24
  end
34
25
  nil
35
26
  end
36
27
 
37
- def instance_for(klass)
38
- cached = (@instances ||= {})[klass]
39
- 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
40
139
 
41
- MUTEX.synchronize do
42
- @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
+ )
43
155
  end
44
156
  end
45
- private_class_method :instance_for
46
157
  end
47
158
  end