llm_cost_tracker 0.8.0 → 0.10.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module IngestionLeases
9
+ REQUIRED_COLUMNS = %w[
10
+ name
11
+ locked_by
12
+ locked_until
13
+ created_at
14
+ updated_at
15
+ ].freeze
16
+
17
+ UNIQUE_COLUMNS = %i[name].freeze
18
+
19
+ class << self
20
+ def current_schema_errors
21
+ connection = LlmCostTracker::Ingestion::Lease.connection
22
+ Adapter.ensure_supported!(connection)
23
+ table_name = LlmCostTracker::Ingestion::Lease.table_name
24
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
25
+
26
+ columns = LlmCostTracker::Ingestion::Lease.columns_hash
27
+ cache = @schema_capabilities
28
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
29
+
30
+ errors = compute_errors(connection, table_name, columns)
31
+ @schema_capabilities = { columns: columns, errors: errors }
32
+ errors
33
+ end
34
+
35
+ private
36
+
37
+ def compute_errors(connection, table_name, columns)
38
+ errors = []
39
+ missing = REQUIRED_COLUMNS - columns.keys
40
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
41
+ errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
42
+ errors
43
+ end
44
+
45
+ def name_unique_index?(connection, table_name)
46
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module ProviderInvoiceImports
9
+ REQUIRED_COLUMNS = %w[
10
+ source provider cursor window_start window_end state last_error
11
+ rows_imported started_at finished_at
12
+ ].freeze
13
+ SOURCE_PROVIDER_STARTED_AT_INDEX = %i[source provider started_at].freeze
14
+
15
+ class << self
16
+ def current_schema_errors
17
+ connection = LlmCostTracker::Call.connection
18
+ Adapter.ensure_supported!(connection)
19
+ table_name = LlmCostTracker::ProviderInvoiceImport.table_name
20
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
21
+
22
+ columns = LlmCostTracker::ProviderInvoiceImport.columns_hash
23
+ cache = @schema_capabilities
24
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
25
+
26
+ errors = compute_errors(connection, table_name, columns)
27
+ @schema_capabilities = { columns: columns, errors: errors }
28
+ errors
29
+ end
30
+
31
+ private
32
+
33
+ def compute_errors(connection, table_name, columns)
34
+ errors = []
35
+ errors.concat(column_errors(columns))
36
+ errors.concat(index_errors(connection, table_name))
37
+ errors
38
+ end
39
+
40
+ def column_errors(columns)
41
+ missing = REQUIRED_COLUMNS - columns.keys
42
+ return [] if missing.empty?
43
+
44
+ ["missing columns: #{missing.join(', ')}"]
45
+ end
46
+
47
+ def index_errors(connection, table_name)
48
+ return [] if connection.index_exists?(table_name, SOURCE_PROVIDER_STARTED_AT_INDEX)
49
+
50
+ ["missing index: source, provider, started_at"]
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -10,7 +10,7 @@ module LlmCostTracker
10
10
  source period_start period_end external_id billed_amount currency metadata imported_at
11
11
  ].freeze
12
12
  UNIQUE_INDEX_COLUMNS = %i[external_id].freeze
13
- SOURCE_PERIOD_INDEX_COLUMNS = %i[source period_start].freeze
13
+ SOURCE_PERIOD_INDEX_COLUMNS = %i[source currency period_start].freeze
14
14
 
15
15
  class << self
16
16
  def current_schema_errors
@@ -19,37 +19,52 @@ module LlmCostTracker
19
19
  table_name = LlmCostTracker::ProviderInvoice.table_name
20
20
  return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
21
21
 
22
- errors = []
23
- errors.concat(column_errors)
24
- errors.concat(metadata_type_errors(connection))
25
- errors.concat(index_errors(connection, table_name))
22
+ columns = LlmCostTracker::ProviderInvoice.columns_hash
23
+ cache = @schema_capabilities
24
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
25
+
26
+ errors = compute_errors(connection, table_name, columns)
27
+ @schema_capabilities = { columns: columns, errors: errors }
26
28
  errors
27
29
  end
28
30
 
29
31
  private
30
32
 
31
- def column_errors
32
- missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoice.columns_hash.keys
33
+ def compute_errors(connection, table_name, columns)
34
+ errors = []
35
+ errors.concat(column_errors(columns))
36
+ errors.concat(Adapter.json_column_errors(columns["metadata"], connection, "metadata"))
37
+ errors.concat(index_errors(connection, table_name))
38
+ errors
39
+ end
40
+
41
+ def column_errors(columns)
42
+ missing = REQUIRED_COLUMNS - columns.keys
33
43
  return [] if missing.empty?
34
44
 
35
45
  ["missing columns: #{missing.join(', ')}"]
36
46
  end
37
47
 
38
- def metadata_type_errors(connection)
39
- metadata = LlmCostTracker::ProviderInvoice.columns_hash["metadata"]
40
- Adapter.json_column_errors(metadata, connection, "metadata")
41
- end
42
-
43
48
  def index_errors(connection, table_name)
44
49
  errors = []
45
50
  unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
46
51
  errors << "missing unique index: external_id"
47
52
  end
48
53
  unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
49
- errors << "missing index: source, period_start"
54
+ errors << "missing index: source, currency, period_start"
55
+ end
56
+ if Adapter.postgresql?(connection) && !gin_metadata_index?(connection, table_name)
57
+ errors << "missing GIN index on metadata " \
58
+ "(run bin/rails generate llm_cost_tracker:upgrade_provider_invoices_metadata_index)"
50
59
  end
51
60
  errors
52
61
  end
62
+
63
+ def gin_metadata_index?(connection, table_name)
64
+ connection.indexes(table_name).any? do |index|
65
+ index.columns == ["metadata"] && index.using.to_s == "gin"
66
+ end
67
+ end
53
68
  end
54
69
  end
55
70
  end
@@ -5,32 +5,40 @@ require "json"
5
5
  require_relative "../pricing"
6
6
  require_relative "../billing/line_item"
7
7
  require_relative "rollups"
8
+ require_relative "tags/encoding"
8
9
 
9
10
  module LlmCostTracker
10
11
  module Ledger
11
12
  class Store
12
13
  class << self
13
- def insert_many(events)
14
+ def insert(events, skip_existence_check: false)
14
15
  events = Array(events)
15
- return [] if events.empty?
16
-
17
- insertable = insertable_events(events)
18
-
19
- if insertable.any?
20
- LlmCostTracker::Call.transaction do
21
- rows = insertable.map { |event| attributes_for(event) }
22
- LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
23
- call_ids = call_ids_for(insertable)
24
- insert_line_items(insertable, call_ids)
25
- insert_call_tags(insertable, call_ids)
26
- Ledger::Rollups.increment_many!(insertable)
27
- end
16
+ return if events.empty?
17
+
18
+ insertable = skip_existence_check ? events : insertable_events(events)
19
+ return unless insertable.any?
20
+
21
+ 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)
28
26
  end
29
- events
27
+ increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
30
28
  end
31
29
 
32
30
  private
33
31
 
32
+ def insert_calls_returning_ids(rows, insertable)
33
+ if LlmCostTracker::Call.connection.supports_insert_returning?
34
+ result = LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: %i[id event_id])
35
+ result.rows.to_h { |id, event_id| [event_id, id] }
36
+ else
37
+ LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
38
+ call_ids_for(insertable)
39
+ end
40
+ end
41
+
34
42
  def attributes_for(event)
35
43
  attributes = {
36
44
  event_id: event.event_id,
@@ -109,7 +117,7 @@ module LlmCostTracker
109
117
  {
110
118
  llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
111
119
  key: key.to_s,
112
- value: tag_row_value(value)
120
+ value: Tags::Encoding.encode(value)
113
121
  }
114
122
  end
115
123
  end
@@ -118,15 +126,18 @@ module LlmCostTracker
118
126
  LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
119
127
  end
120
128
 
121
- def tag_row_value(value)
122
- case value
123
- when Hash, Array then JSON.generate(stored_tag_value(value))
124
- else value.to_s
125
- end
129
+ def stored_details(details)
130
+ (details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
126
131
  end
127
132
 
128
- def stored_details(details)
129
- (details || {}).transform_keys(&:to_s).transform_values { |value| stored_tag_value(value) }
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
+ )
130
141
  end
131
142
 
132
143
  def insertable_events(events)
@@ -138,14 +149,6 @@ module LlmCostTracker
138
149
  !existing_ids.include?(event_id) && seen_ids.add?(event_id)
139
150
  end
140
151
  end
141
-
142
- def stored_tag_value(value)
143
- if value.is_a?(Hash)
144
- return value.transform_keys(&:to_s).transform_values { |nested| stored_tag_value(nested) }
145
- end
146
-
147
- value.to_s
148
- end
149
152
  end
150
153
  end
151
154
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Tags
8
+ module Encoding
9
+ module_function
10
+
11
+ def encode(value)
12
+ case value
13
+ when Hash then JSON.generate(normalize_hash(value))
14
+ when Array then JSON.generate(normalize_array(value))
15
+ else value.to_s
16
+ end
17
+ end
18
+
19
+ def normalize_hash(hash)
20
+ hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
21
+ end
22
+
23
+ def normalize_array(array)
24
+ array.map { |v| normalize_value(v) }
25
+ end
26
+
27
+ def normalize_value(value)
28
+ case value
29
+ when Hash then normalize_hash(value)
30
+ when Array then normalize_array(value)
31
+ else value.to_s
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../schema/adapter"
3
+ require_relative "encoding"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Ledger
@@ -8,7 +8,7 @@ module LlmCostTracker
8
8
  module Query
9
9
  class << self
10
10
  def apply(tags)
11
- normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
11
+ normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
12
12
  return LlmCostTracker::Call.all if normalized_tags.empty?
13
13
 
14
14
  normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
@@ -5,7 +5,8 @@ require_relative "ledger/schema/calls"
5
5
  require_relative "ledger/schema/call_rollups"
6
6
  require_relative "ledger/schema/call_line_items"
7
7
  require_relative "ledger/schema/call_tags"
8
- require_relative "ledger/schema/provider_invoices"
8
+ require_relative "ledger/schema/ingestion_inbox_entries"
9
+ require_relative "ledger/schema/ingestion_leases"
9
10
  require_relative "ledger/tags/query"
10
11
  require_relative "ledger/tags/sql"
11
12
  require_relative "ledger/period"
@@ -9,10 +9,6 @@ module LlmCostTracker
9
9
  log(:debug, message)
10
10
  end
11
11
 
12
- def info(message)
13
- log(:info, message)
14
- end
15
-
16
12
  def warn(message)
17
13
  log(:warn, message)
18
14
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Masking
5
+ SENSITIVE_KEYS = %i[
6
+ provider_api_key_id provider_workspace_id provider_organization_id provider_project_id
7
+ ].to_set.freeze
8
+ MASK_TAIL_LENGTH = 4
9
+
10
+ module_function
11
+
12
+ def mask_value(key, value)
13
+ string = value.to_s
14
+ return string unless SENSITIVE_KEYS.include?(key.to_sym)
15
+ return string if string.length <= MASK_TAIL_LENGTH
16
+
17
+ "***#{string[-MASK_TAIL_LENGTH, MASK_TAIL_LENGTH]}"
18
+ end
19
+
20
+ def format_attribution(attribution, separator: ", ")
21
+ return "" if attribution.nil? || attribution.empty?
22
+
23
+ attribution.map { |key, value| "#{key}=#{mask_value(key, value)}" }.join(separator)
24
+ end
25
+
26
+ def mask_hash(hash)
27
+ return hash unless hash.is_a?(Hash)
28
+
29
+ hash.each_with_object({}) do |(key, value), masked|
30
+ masked[key] = case value
31
+ when Hash then mask_hash(value)
32
+ when Array then value.map { |entry| entry.is_a?(Hash) ? mask_hash(entry) : entry }
33
+ else
34
+ mask_value(key, value)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "stringio"
5
6
  require "uri"
6
7
 
7
8
  require_relative "../logging"
@@ -17,34 +18,116 @@ module LlmCostTracker
17
18
  end
18
19
 
19
20
  def call(request_env)
20
- return @app.call(request_env) unless LlmCostTracker.configuration.enabled
21
+ return @app.call(request_env) unless enabled?
21
22
 
22
23
  request_url = request_env.url.to_s
23
24
  request_body = read_body(request_env.body)
24
25
  parser = Parsers.find_for(request_url)
25
- streaming = parser&.streaming_request?(request_url, request_body)
26
+ request_parsed = parser ? safe_json_parse(request_body) : nil
27
+ streaming = parser&.streaming_request?(request_url, request_parsed)
28
+ if streaming
29
+ request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
30
+ end
26
31
  stream_buffer = install_stream_tap(request_env) if streaming
27
32
 
28
- Tracker.enforce_budget! if parser
33
+ if parser
34
+ Tracker.enforce_budget!(
35
+ provider: parser.provider_for(request_url),
36
+ model: parser.model_for(request_url, request_parsed),
37
+ request: request_parsed
38
+ )
39
+ end
29
40
  context_tags, metadata = tag_snapshot(request_env) if parser
30
41
  started_at = LlmCostTracker::Timing.now_monotonic
31
42
 
43
+ 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
47
+ )
48
+ end
49
+
50
+ private
51
+
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:)
74
+ response_received = false
32
75
  @app.call(request_env).on_complete do |response_env|
76
+ response_received = true
33
77
  process(
34
- parser: parser,
35
- request_url: request_url,
36
- request_body: request_body,
37
- response_env: response_env,
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
82
+ )
83
+ end
84
+ rescue StandardError => e
85
+ if streaming && parser && !response_received
86
+ process_interrupted_stream(
87
+ parser: parser, request_url: request_url, request_body: request_body,
38
88
  latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
39
- streaming: streaming,
40
- stream_buffer: stream_buffer,
41
- context_tags: context_tags,
42
- metadata: metadata
89
+ context_tags: context_tags, metadata: metadata, error: e
43
90
  )
44
91
  end
92
+ raise
45
93
  end
46
94
 
47
- private
95
+ def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
96
+ return nil unless auto_enable_stream_usage?
97
+ return nil unless parser&.auto_enable_stream_usage?(request_url)
98
+
99
+ stream_options = request_parsed["stream_options"]
100
+ return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
101
+
102
+ request_parsed["stream_options"] = (stream_options || {}).merge("include_usage" => true)
103
+ new_body = request_parsed.to_json
104
+ request_env.body = new_body
105
+ new_body
106
+ end
107
+
108
+ def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
109
+ context_tags:, metadata:, error:)
110
+ request = parser.safe_json_parse(request_body)
111
+ event = Event.build(
112
+ provider: parser.provider_for(request_url),
113
+ model: request["model"] || Event::UNKNOWN_MODEL,
114
+ token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
115
+ stream: true,
116
+ usage_source: :unknown
117
+ )
118
+ merged_metadata = (metadata || {}).merge(
119
+ stream_interrupted: true,
120
+ stream_interrupted_error: "#{error.class}: #{error.message}"
121
+ )
122
+ Tracker.record(
123
+ event: event,
124
+ latency_ms: latency_ms,
125
+ metadata: merged_metadata,
126
+ context_tags: context_tags
127
+ )
128
+ rescue StandardError => e
129
+ Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
130
+ end
48
131
 
49
132
  def process(parser:, request_url:, request_body:, response_env:,
50
133
  latency_ms:, streaming:, stream_buffer:, context_tags:, metadata:)
@@ -70,7 +153,7 @@ module LlmCostTracker
70
153
  return unless parsed
71
154
 
72
155
  Tracker.record(
73
- capture: parsed,
156
+ event: parsed,
74
157
  latency_ms: latency_ms,
75
158
  metadata: metadata,
76
159
  context_tags: context_tags
@@ -102,21 +185,14 @@ module LlmCostTracker
102
185
  end
103
186
 
104
187
  def parse_stream(parser:, request_url:, request_body:, response_env:, stream_buffer:)
105
- if stream_buffer&.dig(:overflowed)
106
- Logging.warn(capture_warning(request_url, stream_buffer))
107
- return parser.parse_stream(
108
- request_url: request_url,
109
- request_body: request_body,
110
- response_status: response_env.status,
111
- response_headers: response_env.response_headers
112
- )
113
- end
188
+ overflowed = stream_buffer&.dig(:overflowed) == true
189
+ Logging.warn(capture_warning(request_url, stream_buffer)) if overflowed
114
190
 
115
191
  body = stream_buffer&.dig(:buffer)&.string
116
192
  body = read_body(response_env.body) if body.blank?
117
193
 
118
194
  if body.blank?
119
- Logging.warn(capture_warning(request_url, stream_buffer))
195
+ Logging.warn(capture_warning(request_url, stream_buffer)) unless overflowed
120
196
  return parser.parse_stream(
121
197
  request_url: request_url,
122
198
  request_body: request_body,
@@ -125,7 +201,7 @@ module LlmCostTracker
125
201
  )
126
202
  end
127
203
 
128
- events = Parsers::SSE.parse(body)
204
+ events = overflowed ? [] : Parsers::SSE.parse(body)
129
205
  parser.parse_stream(
130
206
  request_url: request_url,
131
207
  request_body: request_body,
@@ -135,6 +211,17 @@ module LlmCostTracker
135
211
  )
136
212
  end
137
213
 
214
+ def forward_on_data_chunk(callable, chunk, size, env)
215
+ arity = callable.arity
216
+ return callable.call(chunk, size, env) if arity.negative?
217
+
218
+ case arity
219
+ when 0, 1 then callable.call(chunk)
220
+ when 2 then callable.call(chunk, size)
221
+ else callable.call(chunk, size, env)
222
+ end
223
+ end
224
+
138
225
  def install_stream_tap(request_env)
139
226
  request = request_env.request
140
227
  return nil unless request
@@ -145,16 +232,16 @@ module LlmCostTracker
145
232
  state = { buffer: StringIO.new, bytes: 0, overflowed: false }
146
233
  request.on_data = proc do |chunk, size, env|
147
234
  chunk = chunk.to_s
148
- unless state[:overflowed]
149
- if state[:bytes] + chunk.bytesize <= Capture::Stream::LIMIT_BYTES
150
- state[:buffer] << chunk
151
- state[:bytes] += chunk.bytesize
152
- else
153
- state[:overflowed] = true
154
- state[:buffer] = nil
155
- end
235
+ remaining = Capture::Stream::LIMIT_BYTES - state[:bytes]
236
+ if chunk.bytesize <= remaining
237
+ state[:buffer] << chunk
238
+ state[:bytes] += chunk.bytesize
239
+ else
240
+ state[:buffer] << chunk.byteslice(0, remaining) if remaining.positive?
241
+ state[:bytes] += [remaining, 0].max
242
+ state[:overflowed] = true
156
243
  end
157
- original.call(chunk, size, env)
244
+ forward_on_data_chunk(original, chunk, size, env)
158
245
  end
159
246
  state
160
247
  rescue StandardError => e