llm_cost_tracker 0.14.1 → 0.15.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -45,7 +45,10 @@ module LlmCostTracker
45
45
  def already_recorded?(provider:, provider_response_id:)
46
46
  return false if provider_response_id.to_s.empty?
47
47
 
48
- Ledger::Isolation.guard(self) { where(provider: provider, provider_response_id: provider_response_id).exists? }
48
+ Ledger::Isolation.guard(self) do
49
+ where(provider: provider, provider_response_id: provider_response_id)
50
+ .where.not(usage_source: Usage::Source::UNKNOWN).exists?
51
+ end
49
52
  end
50
53
 
51
54
  def by_tag(key, value) = by_tags(key => value)
@@ -66,8 +69,8 @@ module LlmCostTracker
66
69
 
67
70
  def cost_by_tag(key, limit: nil)
68
71
  cost = qualified(:total_cost)
69
- label = Ledger::Tags::Breakdown.label_sql(connection)
70
- raw_value = Ledger::Tags::Breakdown.raw_value_sql(connection)
72
+ label = Ledger::Tags::Breakdown.label_sql(lease_connection)
73
+ raw_value = Ledger::Tags::Breakdown.raw_value_sql(lease_connection)
71
74
  relation = Ledger::Tags::Breakdown.join_relation(self, key)
72
75
  .select("#{label} AS name", "COALESCE(SUM(#{cost}), 0) AS total_cost")
73
76
  .group(Arel.sql(label))
@@ -86,8 +89,14 @@ module LlmCostTracker
86
89
 
87
90
  def latency_by_provider = group(:provider).average(:latency_ms).transform_values(&:to_f)
88
91
 
89
- def group_by_period(period, column: :tracked_at)
90
- group(Arel.sql(period_group_expression(period, column: column)))
92
+ def group_by_period(period, column: :tracked_at, time_zone: nil)
93
+ column = column.to_s
94
+ raise ArgumentError, "invalid period column: #{column.inspect}" unless column_names.include?(column)
95
+
96
+ bucket = Ledger::Schema::Adapter.period_bucket_sql(
97
+ lease_connection, period, qualified(column), time_zone: time_zone
98
+ )
99
+ group(Arel.sql(bucket))
91
100
  end
92
101
 
93
102
  def daily_costs(days: 30)
@@ -97,7 +106,7 @@ module LlmCostTracker
97
106
  end
98
107
 
99
108
  def qualified(column)
100
- "#{quoted_table_name}.#{connection.quote_column_name(column)}"
109
+ "#{quoted_table_name}.#{lease_connection.quote_column_name(column)}"
101
110
  end
102
111
 
103
112
  private
@@ -109,17 +118,6 @@ module LlmCostTracker
109
118
  relation = relation.limit(limit) if limit
110
119
  relation
111
120
  end
112
-
113
- def period_group_expression(period, column:)
114
- Ledger::Schema::Adapter.period_bucket_sql(connection, period, period_column_expression(column))
115
- end
116
-
117
- def period_column_expression(column)
118
- column = column.to_s
119
- return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
120
-
121
- raise ArgumentError, "invalid period column: #{column.inspect}"
122
- end
123
121
  end
124
122
 
125
123
  def tag_pairs
@@ -34,15 +34,15 @@ module LlmCostTracker
34
34
  end
35
35
 
36
36
  def increment_on_duplicate
37
- return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(connection)
38
- return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(connection)
37
+ return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(lease_connection)
38
+ return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(lease_connection)
39
39
 
40
- Ledger::Schema::Adapter.ensure_supported!(connection)
40
+ Ledger::Schema::Adapter.ensure_supported!(lease_connection)
41
41
  end
42
42
 
43
43
  def postgres_increment_sql
44
- total = connection.quote_column_name("total_cost")
45
- updated = connection.quote_column_name("updated_at")
44
+ total = lease_connection.quote_column_name("total_cost")
45
+ updated = lease_connection.quote_column_name("updated_at")
46
46
  "#{total} = #{quoted_table_name}.#{total} + excluded.#{total}, #{updated} = excluded.#{updated}"
47
47
  end
48
48
 
@@ -51,7 +51,7 @@ module LlmCostTracker
51
51
  end
52
52
 
53
53
  def increment_unique_by
54
- return unless connection.supports_insert_conflict_target?
54
+ return unless lease_connection.supports_insert_conflict_target?
55
55
 
56
56
  %i[period period_start currency provider]
57
57
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module CallOrder
6
+ NATURAL_DIRECTIONS = {
7
+ "tracked_at" => "desc",
8
+ "provider" => "asc",
9
+ "model" => "asc",
10
+ "input" => "desc",
11
+ "output" => "desc",
12
+ "cost" => "desc",
13
+ "latency" => "desc"
14
+ }.freeze
15
+ NEWEST_FIRST = { tracked_at: :desc, id: :desc }.freeze
16
+ COST_NULLS_LAST = Arel.sql("CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC")
17
+ LATENCY_NULLS_LAST = Arel.sql("CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC")
18
+ ORDERS = {
19
+ "tracked_at" => ->(dir) { [{ tracked_at: dir, id: dir }] },
20
+ "provider" => ->(dir) { [{ provider: dir, model: :asc, **NEWEST_FIRST }] },
21
+ "model" => ->(dir) { [{ model: dir, **NEWEST_FIRST }] },
22
+ "input" => ->(dir) { [{ input_tokens: dir, **NEWEST_FIRST }] },
23
+ "output" => ->(dir) { [{ output_tokens: dir, **NEWEST_FIRST }] },
24
+ "cost" => ->(dir) { [COST_NULLS_LAST, { total_cost: dir, **NEWEST_FIRST }] },
25
+ "latency" => ->(dir) { [LATENCY_NULLS_LAST, { latency_ms: dir, **NEWEST_FIRST }] }
26
+ }.freeze
27
+
28
+ def self.call(scope, sort:, direction:)
29
+ choice = Sort.resolve(
30
+ sort, direction.to_s.downcase, natural_directions: NATURAL_DIRECTIONS, fallback: "tracked_at"
31
+ )
32
+ scope.order(*ORDERS.fetch(choice.column).call(choice.direction.to_sym))
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+
6
+ module LlmCostTracker
7
+ module Dashboard
8
+ class CallsExport
9
+ MYSQL_HINT = "NO_SKIP_SCAN(llm_cost_tracker_calls)"
10
+ FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
11
+ MASKED_FIELDS = %i[provider_api_key_id provider_workspace_id provider_project_id].freeze
12
+ TEXT_FIELDS = %i[provider model provider_response_id cost_status].freeze
13
+ FIELDS = [
14
+ :tracked_at, :provider, :model, *Usage::TokenUsage.members, :total_cost, :cost_status, :pricing_snapshot,
15
+ :latency_ms, :provider_response_id, :provider_project_id, :provider_api_key_id, :provider_workspace_id,
16
+ :batch, :tags
17
+ ].freeze
18
+
19
+ def self.call(relation, limit:, batch_size:)
20
+ new(relation, limit: limit, batch_size: batch_size).to_s
21
+ end
22
+
23
+ def initialize(relation, limit:, batch_size:)
24
+ @relation = relation
25
+ @limit = limit
26
+ @batch_size = batch_size
27
+ end
28
+
29
+ def to_s
30
+ CSV.generate do |csv|
31
+ csv << FIELDS.map(&:to_s)
32
+ each_call { |call| csv << FIELDS.map { |field| value(field, call) } }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :relation, :limit, :batch_size
39
+
40
+ def each_call(&block)
41
+ scope = relation.limit(limit)
42
+ scope = scope.optimizer_hints(MYSQL_HINT) if Ledger::Schema::Adapter.mysql?(scope.lease_connection)
43
+ scope.pluck(:id).each_slice(batch_size) do |ids|
44
+ calls = LlmCostTracker::Call.where(id: ids).preload(:tag_records).index_by(&:id)
45
+ calls.values_at(*ids).compact.each(&block)
46
+ end
47
+ end
48
+
49
+ def value(field, call)
50
+ case field
51
+ when :tracked_at then call.tracked_at.utc.iso8601
52
+ when *MASKED_FIELDS then formula_safe(Masking.mask_value(field, call[field]))
53
+ when *TEXT_FIELDS then formula_safe(call[field])
54
+ when :pricing_snapshot then formula_safe(Hash(call.pricing_snapshot).deep_stringify_keys.to_json)
55
+ when :tags then formula_safe(call.tag_pairs.to_json)
56
+ else call[field]
57
+ end
58
+ end
59
+
60
+ def formula_safe(value)
61
+ return if value.nil?
62
+
63
+ string = value.to_s
64
+ FORMULA_PREFIXES.include?(string.lstrip[0]) ? "'#{string}" : string
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module DataQuality
6
+ class StatsQuery
7
+ MISSING_RESPONSE_ID = "provider_response_id IS NULL OR provider_response_id = ''"
8
+
9
+ def self.call(scope)
10
+ new(scope).call
11
+ end
12
+
13
+ def initialize(scope)
14
+ @scope = scope
15
+ @connection = scope.lease_connection
16
+ end
17
+
18
+ def call
19
+ scope.unscope(:order).select(selects.join(", ")).take
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :scope, :connection
25
+
26
+ def selects
27
+ [
28
+ "COUNT(*) AS total_calls",
29
+ "#{count_where(Charges::CostStatus.unknown_pricing_sql)} AS unknown_pricing_count",
30
+ "COUNT(*) - #{count_where(tagged_predicate)} AS untagged_calls_count",
31
+ "#{count_where('latency_ms IS NULL')} AS missing_latency_count",
32
+ "#{count_where('stream')} AS streaming_count",
33
+ "#{count_where(streaming_missing_usage_predicate)} AS streaming_missing_usage_count",
34
+ "#{count_where(MISSING_RESPONSE_ID)} AS missing_provider_response_id_count",
35
+ *token_sums,
36
+ "#{billable_tokens} AS billable_tokens",
37
+ "#{hidden_output_share} AS hidden_output_share"
38
+ ]
39
+ end
40
+
41
+ def token_sums
42
+ [*Usage::Catalog.token_priced.map(&:token_key), :hidden_output_tokens].map do |column|
43
+ "#{column_sum(column)} AS #{column}"
44
+ end
45
+ end
46
+
47
+ def billable_tokens
48
+ Usage::Catalog.token_priced.map { |component| column_sum(component.token_key) }.join(" + ")
49
+ end
50
+
51
+ def hidden_output_share
52
+ output = column_sum(:output_tokens)
53
+ "CASE WHEN #{output} > 0 THEN #{column_sum(:hidden_output_tokens)} * 100.0 / #{output} ELSE 0 END"
54
+ end
55
+
56
+ def tagged_predicate
57
+ tags = LlmCostTracker::CallTag.quoted_table_name
58
+ calls = scope.klass.quoted_table_name
59
+ "EXISTS (SELECT 1 FROM #{tags} WHERE #{tags}.llm_cost_tracker_call_id = #{calls}.id " \
60
+ "AND #{tags}.#{connection.quote_column_name('value')} != '')"
61
+ end
62
+
63
+ def streaming_missing_usage_predicate
64
+ "stream AND (#{DataQuality.unknown_usage_sql(connection)})"
65
+ end
66
+
67
+ def column_sum(column)
68
+ "COALESCE(SUM(#{connection.quote_column_name(column)}), 0)"
69
+ end
70
+
71
+ def count_where(predicate)
72
+ "COALESCE(SUM(CASE WHEN #{predicate} THEN 1 ELSE 0 END), 0)"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module DataQuality
6
+ Summary = ::Data.define(
7
+ :total,
8
+ :unknown_pricing_count,
9
+ :untagged_calls_count,
10
+ :missing_latency_count,
11
+ :streaming_count,
12
+ :streaming_missing_usage,
13
+ :missing_provider_response_id_count
14
+ ) do
15
+ def self.from_stats(stats)
16
+ new(
17
+ total: stats.total_calls.to_i,
18
+ unknown_pricing_count: stats.unknown_pricing_count.to_i,
19
+ untagged_calls_count: stats.untagged_calls_count.to_i,
20
+ missing_latency_count: stats.missing_latency_count.to_i,
21
+ streaming_count: stats.streaming_count.to_i,
22
+ streaming_missing_usage: stats.streaming_missing_usage_count.to_i,
23
+ missing_provider_response_id_count: stats.missing_provider_response_id_count.to_i
24
+ )
25
+ end
26
+
27
+ def calls_with_pricing = total - unknown_pricing_count
28
+ def tagged_calls = total - untagged_calls_count
29
+ def calls_with_latency = total - missing_latency_count
30
+ def streams_with_usage = streaming_count - streaming_missing_usage
31
+ def calls_with_provider_response_id = total - missing_provider_response_id_count
32
+
33
+ def unknown_pricing_share = Percent.of(unknown_pricing_count, total)
34
+ def untagged_share = Percent.of(untagged_calls_count, total)
35
+ def missing_latency_share = Percent.of(missing_latency_count, total)
36
+ def streaming_share = Percent.of(streaming_count, total)
37
+ def streaming_missing_usage_share = Percent.of(streaming_missing_usage, streaming_count)
38
+
39
+ def cost_coverage = Percent.of(calls_with_pricing, total)
40
+ def tag_coverage = Percent.of(tagged_calls, total)
41
+ def latency_coverage = Percent.of(calls_with_latency, total)
42
+ def stream_coverage = Percent.of(streams_with_usage, streaming_count)
43
+ def provider_response_id_coverage = Percent.of(calls_with_provider_response_id, total)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -7,37 +7,22 @@ module LlmCostTracker
7
7
  QuarantinedInbox = ::Data.define(:count, :total_cost)
8
8
  UnseenBudgetTags = ::Data.define(:keys)
9
9
  StreamingHealthRow = ::Data.define(:provider, :streams, :with_usage, :unknown, :unknown_share)
10
- Summary = ::Data.define(:total,
11
- :unknown_pricing_count,
12
- :untagged_calls_count,
13
- :missing_latency_count,
14
- :streaming_count,
15
- :streaming_missing_usage,
16
- :missing_provider_response_id_count,
17
- :calls_with_pricing,
18
- :tagged_calls,
19
- :calls_with_latency,
20
- :streams_with_usage,
21
- :calls_with_provider_response_id,
22
- :unknown_pricing_share,
23
- :untagged_share,
24
- :missing_latency_share,
25
- :streaming_share,
26
- :streaming_missing_usage_share,
27
- :cost_coverage,
28
- :tag_coverage,
29
- :latency_coverage,
30
- :stream_coverage,
31
- :provider_response_id_coverage)
32
10
 
33
11
  class << self
34
12
  def call(scope: LlmCostTracker::Call.all)
35
- scope.unscope(:order).select(aggregate_selects(scope)).take
13
+ StatsQuery.call(scope)
14
+ end
15
+
16
+ def summary(stats)
17
+ Summary.from_stats(stats)
18
+ end
19
+
20
+ def unknown_usage_sql(connection)
21
+ "usage_source = #{connection.quote(LlmCostTracker::Usage::Source::UNKNOWN)} OR usage_source IS NULL"
36
22
  end
37
23
 
38
24
  def quarantined_inbox
39
- return nil unless Ingestion.async?
40
- return nil unless Ingestion::InboxEntry.table_exists?
25
+ return nil unless Ingestion.async? && Ingestion::InboxEntry.table_exists?
41
26
 
42
27
  row = Ingestion::InboxEntry
43
28
  .quarantined
@@ -48,54 +33,10 @@ module LlmCostTracker
48
33
 
49
34
  def unseen_budget_tags
50
35
  budgeted = Budget::PerTag.configured
51
- return nil if budgeted.empty?
52
- return nil unless Budget::PerTag.columns?
53
- return nil unless LlmCostTracker::CallTag.exists?
54
-
55
- unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.exists?(key: key) }
56
- return nil if unseen.empty?
57
-
58
- UnseenBudgetTags.new(keys: unseen)
59
- end
60
-
61
- def summary(stats)
62
- total = stats.total_calls.to_i
63
- unknown_pricing_count = stats.unknown_pricing_count.to_i
64
- untagged_calls_count = stats.untagged_calls_count.to_i
65
- missing_latency_count = stats.missing_latency_count.to_i
66
- streaming_count = stats.streaming_count.to_i
67
- streaming_missing_usage = stats.streaming_missing_usage_count.to_i
68
- missing_provider_response_id_count = stats.missing_provider_response_id_count.to_i
69
- calls_with_pricing = total - unknown_pricing_count
70
- tagged_calls = total - untagged_calls_count
71
- calls_with_latency = total - missing_latency_count
72
- streams_with_usage = streaming_count - streaming_missing_usage
73
- calls_with_provider_response_id = total - missing_provider_response_id_count
36
+ return nil if budgeted.empty? || !Budget::PerTag.columns? || !LlmCostTracker::CallTag.exists?
74
37
 
75
- Summary.new(
76
- total,
77
- unknown_pricing_count,
78
- untagged_calls_count,
79
- missing_latency_count,
80
- streaming_count,
81
- streaming_missing_usage,
82
- missing_provider_response_id_count,
83
- calls_with_pricing,
84
- tagged_calls,
85
- calls_with_latency,
86
- streams_with_usage,
87
- calls_with_provider_response_id,
88
- percentage(unknown_pricing_count, total),
89
- percentage(untagged_calls_count, total),
90
- percentage(missing_latency_count, total),
91
- percentage(streaming_count, total),
92
- percentage(streaming_missing_usage, streaming_count),
93
- percentage(calls_with_pricing, total),
94
- percentage(tagged_calls, total),
95
- percentage(calls_with_latency, total),
96
- percentage(streams_with_usage, streaming_count),
97
- percentage(calls_with_provider_response_id, total)
98
- )
38
+ unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.where(key: key).where.not(value: "").exists? }
39
+ UnseenBudgetTags.new(keys: unseen) unless unseen.empty?
99
40
  end
100
41
 
101
42
  def unknown_pricing_by_model(scope, total_calls:)
@@ -109,103 +50,57 @@ module LlmCostTracker
109
50
  UnknownPricingRow.new(provider: row.provider,
110
51
  model: row.model,
111
52
  calls: calls,
112
- share_percent: percentage(calls, total_calls))
53
+ share_percent: Percent.of(calls, total_calls))
113
54
  end
114
55
  end
115
56
 
116
57
  def service_charge_rows(scope)
117
58
  call_table = LlmCostTracker::Call.quoted_table_name
118
- line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
119
- relation = LlmCostTracker::CallLineItem
120
- .where.not(unit: "token")
121
- .joins(:call)
122
- .merge(scope.unscope(:select, :order))
123
-
124
- relation
125
- .group("#{call_table}.provider", "#{line_item_table}.kind", "#{line_item_table}.cost_status")
126
- .order(Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0) DESC"), Arel.sql("COUNT(*) DESC"))
59
+ item_table = LlmCostTracker::CallLineItem.quoted_table_name
60
+
61
+ LlmCostTracker::CallLineItem
62
+ .where.not(unit: "token")
63
+ .joins(:call)
64
+ .merge(scope.unscope(:select, :order))
65
+ .group("#{call_table}.provider", "#{item_table}.kind", "#{item_table}.cost_status")
66
+ .order(Arel.sql("COALESCE(SUM(#{item_table}.cost), 0) DESC"), Arel.sql("COUNT(*) DESC"))
127
67
  .select(
128
68
  "#{call_table}.provider AS provider",
129
- "#{line_item_table}.kind AS component",
130
- "#{line_item_table}.cost_status AS cost_status",
69
+ "#{item_table}.kind AS component",
70
+ "#{item_table}.cost_status AS cost_status",
131
71
  "COUNT(*) AS charges_count",
132
- "COALESCE(SUM(#{line_item_table}.quantity), 0) AS quantity",
133
- "COALESCE(SUM(#{line_item_table}.cost), 0) AS total_cost"
72
+ "COALESCE(SUM(#{item_table}.quantity), 0) AS quantity",
73
+ "COALESCE(SUM(#{item_table}.cost), 0) AS total_cost"
134
74
  )
135
75
  .limit(10)
136
76
  end
137
77
 
78
+ def component_costs(scope)
79
+ item_table = LlmCostTracker::CallLineItem.quoted_table_name
80
+ dimensions = %w[kind direction cache_state].map { |column| "#{item_table}.#{column}" }
81
+ rows = LlmCostTracker::CallLineItem
82
+ .where(unit: "token")
83
+ .joins(:call)
84
+ .merge(scope.unscope(:select, :order, :group))
85
+ .group(*dimensions)
86
+ .pluck(*dimensions.map { |column| Arel.sql(column) }, Arel.sql("COALESCE(SUM(#{item_table}.cost), 0)"))
87
+ Usage::Catalog.costs_by_component(rows)
88
+ end
89
+
138
90
  def usage_rows(stats, component_costs: {})
139
91
  billable_tokens = stats.billable_tokens.to_f
140
-
141
92
  rows = Usage::Catalog.token_priced.map do |component|
142
93
  token_value = stats[component.token_key].to_i
143
-
144
94
  {
145
- price_key: component.key,
146
95
  token_key: component.token_key,
147
96
  cost_key: component.cost_key,
148
97
  token_value: token_value,
149
98
  cost_value: component_costs[component.key],
150
- share_percent: percentage(token_value, billable_tokens),
99
+ share_percent: Percent.of(token_value, billable_tokens),
151
100
  share_basis: nil
152
101
  }
153
102
  end
154
-
155
- rows + [
156
- {
157
- price_key: nil,
158
- token_key: :hidden_output_tokens,
159
- cost_key: nil,
160
- token_value: stats.hidden_output_tokens.to_i,
161
- cost_value: nil,
162
- share_percent: stats.hidden_output_share.to_f,
163
- share_basis: :output
164
- }
165
- ]
166
- end
167
-
168
- def component_costs(scope)
169
- line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
170
- rows = LlmCostTracker::CallLineItem
171
- .where(unit: "token")
172
- .joins(:call)
173
- .merge(scope.unscope(:select, :order, :group))
174
- .group("#{line_item_table}.kind",
175
- "#{line_item_table}.direction",
176
- "#{line_item_table}.cache_state")
177
- .pluck(Arel.sql("#{line_item_table}.kind"),
178
- Arel.sql("#{line_item_table}.direction"),
179
- Arel.sql("#{line_item_table}.cache_state"),
180
- Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0)"))
181
- index_costs_by_component(rows)
182
- end
183
-
184
- def streaming_health_rows(scope, total_streaming:)
185
- return [] unless total_streaming.positive?
186
-
187
- unknown_predicate = unknown_usage_source_predicate(scope)
188
- rows = scope.unscope(:select, :order, :group)
189
- .where(stream: true)
190
- .group(:provider)
191
- .order(Arel.sql("COUNT(*) DESC"), :provider)
192
- .pluck(
193
- :provider,
194
- Arel.sql("COUNT(*)"),
195
- Arel.sql("SUM(CASE WHEN #{unknown_predicate} THEN 1 ELSE 0 END)")
196
- )
197
-
198
- rows.map do |provider, streams, unknown|
199
- streams_count = streams.to_i
200
- unknown_count = unknown.to_i
201
- StreamingHealthRow.new(
202
- provider: provider,
203
- streams: streams_count,
204
- with_usage: streams_count - unknown_count,
205
- unknown: unknown_count,
206
- unknown_share: percentage(unknown_count, streams_count)
207
- )
208
- end
103
+ rows << hidden_output_usage_row(stats)
209
104
  end
210
105
 
211
106
  def hidden_output_summary(stats)
@@ -219,89 +114,39 @@ module LlmCostTracker
219
114
  }
220
115
  end
221
116
 
222
- private
223
-
224
- def index_costs_by_component(rows)
225
- rows.each_with_object({}) do |(kind, direction, cache_state, cost), accumulator|
226
- component = Usage::Catalog.token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
227
- accumulator[component.key] = cost if component
228
- end
229
- end
230
-
231
- def percentage(numerator, denominator)
232
- return 0.0 unless denominator.positive?
233
-
234
- (numerator.to_f / denominator) * 100.0
235
- end
236
-
237
- def aggregate_selects(scope)
238
- unknown_pricing = Charges::CostStatus.unknown_pricing_sql
239
- selects = [
240
- "COUNT(*) AS total_calls",
241
- "#{conditional_count_sql(unknown_pricing)} AS unknown_pricing_count",
242
- "#{tagged_calls_sql(scope)} AS tagged_calls_count",
243
- "COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
244
- "#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
245
- "#{conditional_count_sql('stream')} AS streaming_count",
246
- "#{streaming_missing_usage_select(scope)} AS streaming_missing_usage_count",
247
- "#{provider_response_id_select} AS missing_provider_response_id_count"
248
- ]
249
-
250
- usage_sum_columns.each do |column|
251
- selects << "#{column_sum(scope, column)} AS #{column}"
252
- end
253
-
254
- selects << "#{billable_tokens_select(scope)} AS billable_tokens"
255
- selects << "#{hidden_output_share_select(scope)} AS hidden_output_share"
256
-
257
- selects.join(", ")
258
- end
259
-
260
- def usage_sum_columns
261
- Usage::Catalog.token_priced.map(&:token_key) + [:hidden_output_tokens]
262
- end
263
-
264
- def billable_tokens_select(scope)
265
- Usage::Catalog.token_priced
266
- .map { |component| column_sum(scope, component.token_key) }
267
- .join(" + ")
268
- end
269
-
270
- def hidden_output_share_select(scope)
271
- hidden_output = column_sum(scope, :hidden_output_tokens)
272
- output = column_sum(scope, :output_tokens)
273
-
274
- "CASE WHEN #{output} > 0 THEN #{hidden_output} * 100.0 / #{output} ELSE 0 END"
275
- end
276
-
277
- def unknown_usage_source_predicate(scope)
278
- quoted = scope.connection.quote(LlmCostTracker::Usage::Source::UNKNOWN)
279
- "usage_source = #{quoted} OR usage_source IS NULL"
280
- end
281
-
282
- def column_sum(scope, column)
283
- "COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)"
284
- end
117
+ def streaming_health_rows(scope, total_streaming:)
118
+ return [] unless total_streaming.positive?
285
119
 
286
- def conditional_count_sql(predicate)
287
- "COALESCE(SUM(CASE WHEN #{predicate} THEN 1 ELSE 0 END), 0)"
120
+ unknown_count = Arel.sql("SUM(CASE WHEN #{unknown_usage_sql(scope.lease_connection)} THEN 1 ELSE 0 END)")
121
+ scope.unscope(:select, :order, :group)
122
+ .where(stream: true)
123
+ .group(:provider)
124
+ .order(Arel.sql("COUNT(*) DESC"), :provider)
125
+ .pluck(:provider, Arel.sql("COUNT(*)"), unknown_count)
126
+ .map { |provider, streams, unknown| streaming_health_row(provider, streams.to_i, unknown.to_i) }
288
127
  end
289
128
 
290
- def streaming_missing_usage_select(scope)
291
- conditional_count_sql("stream AND (#{unknown_usage_source_predicate(scope)})")
292
- end
129
+ private
293
130
 
294
- def provider_response_id_select
295
- predicate = "provider_response_id IS NULL OR provider_response_id = ''"
296
- conditional_count_sql(predicate)
131
+ def hidden_output_usage_row(stats)
132
+ {
133
+ token_key: :hidden_output_tokens,
134
+ cost_key: nil,
135
+ token_value: stats.hidden_output_tokens.to_i,
136
+ cost_value: nil,
137
+ share_percent: stats.hidden_output_share.to_f,
138
+ share_basis: :output
139
+ }
297
140
  end
298
141
 
299
- def tagged_calls_sql(scope)
300
- calls_table = scope.klass.quoted_table_name
301
- tags_table = LlmCostTracker::CallTag.quoted_table_name
302
-
303
- "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM #{tags_table} " \
304
- "WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id) THEN 1 ELSE 0 END), 0)"
142
+ def streaming_health_row(provider, streams, unknown)
143
+ StreamingHealthRow.new(
144
+ provider: provider,
145
+ streams: streams,
146
+ with_usage: streams - unknown,
147
+ unknown: unknown,
148
+ unknown_share: Percent.of(unknown, streams)
149
+ )
305
150
  end
306
151
  end
307
152
  end