llm_cost_tracker 0.7.0 → 0.7.1

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +11 -9
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +28 -35
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  89. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  91. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  92. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  93. data/lib/llm_cost_tracker/parsers.rb +20 -0
  94. data/lib/llm_cost_tracker/prices.json +52 -11
  95. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  96. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  97. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  98. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  99. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  100. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  101. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  102. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  104. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  105. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  106. data/lib/llm_cost_tracker/pricing.rb +33 -32
  107. data/lib/llm_cost_tracker/railtie.rb +7 -8
  108. data/lib/llm_cost_tracker/report/data.rb +72 -0
  109. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  110. data/lib/llm_cost_tracker/report.rb +8 -8
  111. data/lib/llm_cost_tracker/retention.rb +27 -10
  112. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  113. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  114. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  115. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  116. data/lib/llm_cost_tracker/tracker.rb +38 -70
  117. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  118. data/lib/llm_cost_tracker/version.rb +1 -1
  119. data/lib/llm_cost_tracker.rb +56 -78
  120. data/lib/tasks/llm_cost_tracker.rake +18 -13
  121. metadata +54 -58
  122. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  123. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  124. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  125. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  126. data/lib/llm_cost_tracker/cost.rb +0 -12
  127. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  128. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  129. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  130. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  131. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  132. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  133. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  134. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  135. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  136. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  137. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  138. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  139. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  140. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  141. data/lib/llm_cost_tracker/period_total.rb +0 -9
  142. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  143. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  144. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  145. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  146. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  147. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  148. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  149. data/lib/llm_cost_tracker/report_data.rb +0 -94
  150. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  151. data/lib/llm_cost_tracker/request_url.rb +0 -20
  152. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  153. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  154. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  155. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  156. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  157. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  158. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  159. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  160. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  161. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  162. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  163. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  164. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  165. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  166. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  167. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  168. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  169. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  170. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  171. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  172. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- module Storage
5
- module ActiveRecordPeriods
4
+ module Ledger
5
+ module Period
6
6
  PERIODS = {
7
7
  monthly: "month",
8
8
  daily: "day"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "../period"
6
+
7
+ module LlmCostTracker
8
+ module Ledger
9
+ class Rollups
10
+ class Batch
11
+ def self.rows(events)
12
+ new(events).rows
13
+ end
14
+
15
+ def initialize(events)
16
+ @events = events
17
+ end
18
+
19
+ def rows
20
+ totals.map do |(period, period_start), total_cost|
21
+ {
22
+ period: period,
23
+ period_start: period_start,
24
+ total_cost: total_cost
25
+ }
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :events
32
+
33
+ def totals
34
+ events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
35
+ Period::PERIODS.each do |period, name|
36
+ rows[[name, Period.bucket(period, event.tracked_at)]] += BigDecimal(event.total_cost.to_s)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schema/adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ class Rollups
8
+ class UpsertSql
9
+ def self.call(model)
10
+ new(model).call
11
+ end
12
+
13
+ def initialize(model)
14
+ @model = model
15
+ end
16
+
17
+ def call
18
+ return Arel.sql(mysql_sql) if Ledger::Schema::Adapter.mysql?(connection)
19
+ return Arel.sql(postgres_sql) if Ledger::Schema::Adapter.postgresql?(connection)
20
+
21
+ Ledger::Schema::Adapter.ensure_supported!(connection)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :model
27
+
28
+ def postgres_sql
29
+ total_cost = connection.quote_column_name("total_cost")
30
+ updated_at = connection.quote_column_name("updated_at")
31
+
32
+ "#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
33
+ "#{updated_at} = excluded.#{updated_at}"
34
+ end
35
+
36
+ def mysql_sql
37
+ "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
38
+ end
39
+
40
+ def connection
41
+ model.connection
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ require_relative "period"
6
+ require_relative "rollups/batch"
7
+ require_relative "rollups/upsert_sql"
8
+
9
+ module LlmCostTracker
10
+ module Ledger
11
+ class Rollups
12
+ class << self
13
+ def increment!(event)
14
+ return unless event.total_cost
15
+
16
+ Period::Total.upsert_all(
17
+ period_rows(event),
18
+ on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
19
+ record_timestamps: true,
20
+ unique_by: unique_by(Period::Total, %i[period period_start])
21
+ )
22
+ end
23
+
24
+ def increment_many!(events)
25
+ events = Array(events).select(&:total_cost)
26
+ return if events.empty?
27
+
28
+ Period::Total.upsert_all(
29
+ Ledger::Rollups::Batch.rows(events),
30
+ on_duplicate: Ledger::Rollups::UpsertSql.call(Period::Total),
31
+ record_timestamps: true,
32
+ unique_by: unique_by(Period::Total, %i[period period_start])
33
+ )
34
+ end
35
+
36
+ def decrement!(call_rows)
37
+ totals = period_decrement_totals(call_rows)
38
+ return if totals.empty?
39
+
40
+ apply_decrements(totals)
41
+ end
42
+
43
+ private
44
+
45
+ def period_rows(event)
46
+ Period::PERIODS.map do |period, name|
47
+ {
48
+ period: name,
49
+ period_start: Period.bucket(period, event.tracked_at),
50
+ total_cost: event.total_cost
51
+ }
52
+ end
53
+ end
54
+
55
+ def period_decrement_totals(call_rows)
56
+ call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
57
+ _id, tracked_at, total_cost = row
58
+ next unless total_cost
59
+
60
+ Period::PERIODS.each_key do |period|
61
+ totals[[period, Period.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
62
+ end
63
+ end
64
+ end
65
+
66
+ def apply_decrements(totals)
67
+ now = Time.now.utc
68
+
69
+ totals.each do |(period, period_start), amount|
70
+ row = Period::Total.lock.find_by(period: Period::PERIODS.fetch(period),
71
+ period_start: period_start)
72
+ next unless row
73
+
74
+ row.update_columns(total_cost: [BigDecimal(row.total_cost.to_s) - amount, BigDecimal("0")].max,
75
+ updated_at: now)
76
+ end
77
+ end
78
+
79
+ def unique_by(model, column)
80
+ return unless model.connection.supports_insert_conflict_target?
81
+
82
+ column
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module Adapter
9
+ MYSQL_ADAPTERS = %w[
10
+ ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
11
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter
12
+ ActiveRecord::ConnectionAdapters::TrilogyAdapter
13
+ ].freeze
14
+ POSTGRESQL_ADAPTERS = %w[
15
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
16
+ ].freeze
17
+ MYSQL_PATTERN = /mysql|trilogy|mariadb/i
18
+ POSTGRESQL_PATTERN = /postgres/i
19
+
20
+ class << self
21
+ def mysql?(value)
22
+ adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
23
+ end
24
+
25
+ def postgresql?(value)
26
+ adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
27
+ end
28
+
29
+ def ensure_supported!(value)
30
+ return if mysql?(value) || postgresql?(value)
31
+
32
+ raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
33
+ end
34
+
35
+ private
36
+
37
+ def adapter_instance?(value, class_names)
38
+ class_names.any? do |class_name|
39
+ adapter_class = class_name.safe_constantize
40
+ adapter_class && value.is_a?(adapter_class)
41
+ end
42
+ end
43
+
44
+ def adapter_name(value)
45
+ value.try(:adapter_name).presence || value.to_s
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "llm_cost_tracker/ledger/schema/adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module Calls
9
+ CURRENT_SCHEMA_COLUMNS = %w[
10
+ event_id
11
+ provider
12
+ model
13
+ input_tokens
14
+ output_tokens
15
+ total_tokens
16
+ cache_read_input_tokens
17
+ cache_write_input_tokens
18
+ cache_write_1h_input_tokens
19
+ hidden_output_tokens
20
+ input_cost
21
+ output_cost
22
+ total_cost
23
+ cache_read_input_cost
24
+ cache_write_input_cost
25
+ cache_write_1h_input_cost
26
+ latency_ms
27
+ stream
28
+ usage_source
29
+ provider_response_id
30
+ pricing_mode
31
+ tags
32
+ tracked_at
33
+ ].freeze
34
+
35
+ class << self
36
+ def current_schema?
37
+ current_schema_errors.empty?
38
+ end
39
+
40
+ def current_schema_errors
41
+ schema_capabilities.fetch(:current_schema_errors)
42
+ end
43
+
44
+ def missing_current_schema_columns
45
+ schema_capabilities.fetch(:missing_current_schema_columns)
46
+ end
47
+
48
+ private
49
+
50
+ def schema_capabilities
51
+ columns = Ledger::Call.columns_hash
52
+ adapter_name = Ledger::Call.connection.adapter_name
53
+ cache = @schema_capabilities
54
+
55
+ return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
56
+ cache.fetch(:adapter_name) == adapter_name
57
+
58
+ values = build_schema_capabilities(columns, adapter_name)
59
+ @schema_capabilities = { columns: columns, adapter_name: adapter_name, values: values }
60
+ values
61
+ end
62
+
63
+ def build_schema_capabilities(columns, adapter_name)
64
+ Ledger::Schema::Adapter.ensure_supported!(adapter_name)
65
+
66
+ {
67
+ missing_current_schema_columns: missing_columns_for(columns),
68
+ current_schema_errors: schema_errors_for(columns, adapter_name)
69
+ }
70
+ end
71
+
72
+ def schema_errors_for(columns, adapter_name)
73
+ errors = []
74
+ missing = missing_columns_for(columns)
75
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
76
+
77
+ tag_column = columns["tags"]
78
+ if tag_column
79
+ postgresql = Ledger::Schema::Adapter.postgresql?(adapter_name)
80
+ expected_type = postgresql ? "jsonb" : "json"
81
+ valid_type =
82
+ if postgresql
83
+ tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb"
84
+ else
85
+ tag_column.type == :json
86
+ end
87
+
88
+ errors << "tags column must use #{expected_type}" unless valid_type
89
+ end
90
+
91
+ errors
92
+ end
93
+
94
+ def missing_columns_for(columns)
95
+ CURRENT_SCHEMA_COLUMNS - columns.keys
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Ledger
5
+ module Schema
6
+ module PeriodTotals
7
+ REQUIRED_COLUMNS = %w[period period_start total_cost].freeze
8
+ UNIQUE_COLUMNS = %i[period period_start].freeze
9
+
10
+ class << self
11
+ def current_schema_errors
12
+ connection = Ledger::Call.connection
13
+ table_name = Ledger::Period::Total.table_name
14
+ return ["llm_cost_tracker_period_totals table is missing"] unless connection.data_source_exists?(table_name)
15
+
16
+ errors = []
17
+ missing = REQUIRED_COLUMNS - Ledger::Period::Total.columns_hash.keys
18
+ errors << "missing columns: #{missing.join(', ')}" if missing.any?
19
+ errors << "missing unique index: period, period_start" unless unique_period_index?(connection, table_name)
20
+ errors
21
+ end
22
+
23
+ private
24
+
25
+ def unique_period_index?(connection, table_name)
26
+ connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../pricing"
4
+ require_relative "rollups"
5
+
6
+ module LlmCostTracker
7
+ module Ledger
8
+ class Store
9
+ class << self
10
+ def insert_many(events)
11
+ events = Array(events)
12
+ return [] if events.empty?
13
+
14
+ model = LlmCostTracker::Ledger::Call
15
+ insertable = new_events(model, events)
16
+
17
+ if insertable.any?
18
+ rows = insertable.map { |event| attributes_for(event) }
19
+ model.insert_all!(rows, record_timestamps: true, returning: false)
20
+ Ledger::Rollups.increment_many!(insertable)
21
+ end
22
+ events
23
+ end
24
+
25
+ private
26
+
27
+ def attributes_for(event)
28
+ tags = (event.tags || {}).transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
29
+ usage = event.token_usage.stored_attributes
30
+
31
+ attributes = {
32
+ event_id: event.event_id,
33
+ provider: event.provider,
34
+ model: event.model,
35
+ tags: tags,
36
+ tracked_at: event.tracked_at,
37
+ pricing_mode: event.pricing_mode,
38
+ latency_ms: event.latency_ms,
39
+ stream: event.stream,
40
+ usage_source: event.usage_source,
41
+ provider_response_id: event.provider_response_id
42
+ }
43
+
44
+ attributes.merge(usage).merge(Pricing.stored_cost_attributes(event.cost || {}))
45
+ end
46
+
47
+ def new_events(model, events)
48
+ existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
49
+ events.reject { |event| existing_ids.include?(event.event_id) }
50
+ end
51
+
52
+ def stringify_tag_value(value)
53
+ return value.transform_values { |nested| stringify_tag_value(nested) } if value.is_a?(Hash)
54
+
55
+ value.to_s
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../schema/adapter"
6
+
7
+ module LlmCostTracker
8
+ module Ledger
9
+ module Tags
10
+ module Query
11
+ class << self
12
+ def apply(model, tags)
13
+ normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
14
+ return model.all if normalized_tags.empty?
15
+
16
+ connection = model.connection
17
+ json = normalized_tags.to_json
18
+
19
+ if Schema::Adapter.postgresql?(connection)
20
+ model.where("tags @> ?::jsonb", json)
21
+ else
22
+ model.where("JSON_CONTAINS(tags, ?)", json)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schema/adapter"
4
+ require_relative "../../tags/key"
5
+
6
+ module LlmCostTracker
7
+ module Ledger
8
+ module Tags
9
+ module Sql
10
+ class << self
11
+ def value_expression(model, key, table_name:)
12
+ key = LlmCostTracker::Tags::Key.validate!(key)
13
+ column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
14
+
15
+ if Ledger::Schema::Adapter.postgresql?(model.connection)
16
+ "#{column}->>#{model.connection.quote(key)}"
17
+ elsif Ledger::Schema::Adapter.mysql?(model.connection)
18
+ "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
19
+ else
20
+ Ledger::Schema::Adapter.ensure_supported!(model.connection)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def json_path(key)
27
+ "$.\"#{key}\""
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ledger/schema/adapter"
4
+ require_relative "ledger/schema/calls"
5
+ require_relative "ledger/schema/period_totals"
6
+ require_relative "ledger/tags/query"
7
+ require_relative "ledger/tags/sql"
8
+ require_relative "ledger/period"
9
+ require_relative "ledger/rollups/batch"
10
+ require_relative "ledger/rollups/upsert_sql"
11
+ require_relative "ledger/rollups"
12
+ require_relative "ledger/store"
13
+ require_relative "ledger/period/totals"
@@ -19,9 +19,10 @@ module LlmCostTracker
19
19
 
20
20
  def log(level, message)
21
21
  message = prefixed(message)
22
+ logger = Rails.logger
22
23
 
23
- if rails_logger
24
- rails_logger.public_send(level, message)
24
+ if logger
25
+ logger.try(level, message)
25
26
  else
26
27
  Kernel.warn(message)
27
28
  end
@@ -35,10 +36,6 @@ module LlmCostTracker
35
36
 
36
37
  "#{PREFIX} #{message}"
37
38
  end
38
-
39
- def rails_logger
40
- Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
41
- end
42
39
  end
43
40
  end
44
41
  end
@@ -2,10 +2,10 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "uri"
5
6
 
6
7
  require_relative "../logging"
7
- require_relative "../request_url"
8
- require_relative "../stream_capture"
8
+ require_relative "../capture/stream"
9
9
 
10
10
  module LlmCostTracker
11
11
  module Middleware
@@ -20,12 +20,12 @@ module LlmCostTracker
20
20
 
21
21
  request_url = request_env.url.to_s
22
22
  request_body = read_body(request_env.body) || ""
23
- parser = Parsers::Registry.find_for(request_url)
23
+ parser = Parsers.find_for(request_url)
24
24
  streaming = parser&.streaming_request?(request_url, request_body)
25
25
  stream_buffer = install_stream_tap(request_env) if streaming
26
26
 
27
27
  Tracker.enforce_budget! if parser
28
- started_at = monotonic_time
28
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
29
 
30
30
  @app.call(request_env).on_complete do |response_env|
31
31
  process(
@@ -34,7 +34,7 @@ module LlmCostTracker
34
34
  request_url: request_url,
35
35
  request_body: request_body,
36
36
  response_env: response_env,
37
- latency_ms: elapsed_ms(started_at),
37
+ latency_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round,
38
38
  streaming: streaming,
39
39
  stream_buffer: stream_buffer
40
40
  )
@@ -56,15 +56,9 @@ module LlmCostTracker
56
56
  return unless parsed
57
57
 
58
58
  Tracker.record(
59
- provider: parsed.provider,
60
- model: parsed.model,
61
- input_tokens: parsed.input_tokens,
62
- output_tokens: parsed.output_tokens,
59
+ capture: parsed,
63
60
  latency_ms: latency_ms,
64
- stream: parsed.stream,
65
- usage_source: parsed.usage_source,
66
- provider_response_id: parsed.provider_response_id,
67
- metadata: resolved_tags(request_env).merge(parsed.metadata)
61
+ metadata: resolved_tags(request_env)
68
62
  )
69
63
  rescue LlmCostTracker::Error
70
64
  raise
@@ -76,7 +70,7 @@ module LlmCostTracker
76
70
  response_body = read_body(response_env.body)
77
71
  unless response_body
78
72
  Logging.warn(
79
- "Unable to read response body for #{RequestUrl.label(request_url)}; " \
73
+ "Unable to read response body for #{request_url_label(request_url)}; " \
80
74
  "known streaming responses are captured automatically, or via LlmCostTracker.track_stream " \
81
75
  "for custom clients."
82
76
  )
@@ -93,9 +87,9 @@ module LlmCostTracker
93
87
  end
94
88
 
95
89
  body = stream_buffer&.dig(:buffer)&.string
96
- body = read_body(response_env.body) if body.nil? || body.empty?
90
+ body = read_body(response_env.body) if body.blank?
97
91
 
98
- if body.nil? || body.empty?
92
+ if body.blank?
99
93
  Logging.warn(capture_warning(request_url, stream_buffer))
100
94
  return parser.parse_stream(request_url, request_body, response_env.status, [])
101
95
  end
@@ -105,16 +99,17 @@ module LlmCostTracker
105
99
  end
106
100
 
107
101
  def install_stream_tap(request_env)
108
- return nil unless request_env.respond_to?(:request) && request_env.request
102
+ request = request_env.try(:request)
103
+ return nil unless request
109
104
 
110
- original = request_env.request.on_data
105
+ original = request.on_data
111
106
  return nil unless original
112
107
 
113
108
  state = { buffer: StringIO.new, bytes: 0, overflowed: false }
114
- request_env.request.on_data = proc do |chunk, size, env|
109
+ request.on_data = proc do |chunk, size, env|
115
110
  chunk = chunk.to_s
116
111
  unless state[:overflowed]
117
- if state[:bytes] + chunk.bytesize <= StreamCapture::LIMIT_BYTES
112
+ if state[:bytes] + chunk.bytesize <= Capture::Stream::LIMIT_BYTES
118
113
  state[:buffer] << chunk
119
114
  state[:bytes] += chunk.bytesize
120
115
  else
@@ -136,38 +131,42 @@ module LlmCostTracker
136
131
  when nil then ""
137
132
  when Hash, Array then body.to_json
138
133
  else
139
- body.respond_to?(:to_str) ? body.to_str : nil
134
+ body.try(:to_str)
140
135
  end
141
136
  end
142
137
 
143
138
  def resolved_tags(request_env)
144
- tags = @tags.respond_to?(:call) ? call_tags(request_env) : @tags
139
+ tags =
140
+ if @tags.respond_to?(:call)
141
+ @tags.arity.zero? ? @tags.call : @tags.call(request_env)
142
+ else
143
+ @tags
144
+ end
145
145
  return {} if tags.nil?
146
146
 
147
147
  tags.to_h
148
148
  end
149
149
 
150
- def call_tags(request_env)
151
- @tags.arity.zero? ? @tags.call : @tags.call(request_env)
152
- end
153
-
154
- def monotonic_time
155
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
- end
157
-
158
- def elapsed_ms(started_at)
159
- ((monotonic_time - started_at) * 1000).round
160
- end
161
-
162
150
  def capture_warning(request_url, stream_buffer)
163
151
  unless stream_buffer&.dig(:overflowed)
164
- return "Unable to capture streaming response for #{RequestUrl.label(request_url)}; " \
152
+ return "Unable to capture streaming response for #{request_url_label(request_url)}; " \
165
153
  "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
166
154
  end
167
155
 
168
- "Streaming response for #{RequestUrl.label(request_url)} exceeded #{StreamCapture::LIMIT_BYTES} bytes; " \
156
+ "Streaming response for #{request_url_label(request_url)} exceeded #{Capture::Stream::LIMIT_BYTES} bytes; " \
169
157
  "recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
170
158
  end
159
+
160
+ def request_url_label(value)
161
+ uri = URI.parse(value.to_s)
162
+ uri.query = nil
163
+ uri.fragment = nil
164
+ uri.try(:user=, nil)
165
+ uri.try(:password=, nil)
166
+ uri.to_s
167
+ rescue URI::InvalidURIError
168
+ value.to_s.split("?", 2).first
169
+ end
171
170
  end
172
171
  end
173
172
  end