llm_cost_tracker 0.14.2 → 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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -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,36 +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
- :calls_with_pricing,
17
- :tagged_calls,
18
- :calls_with_latency,
19
- :streams_with_usage,
20
- :calls_with_provider_response_id,
21
- :unknown_pricing_share,
22
- :untagged_share,
23
- :missing_latency_share,
24
- :streaming_share,
25
- :streaming_missing_usage_share,
26
- :cost_coverage,
27
- :tag_coverage,
28
- :latency_coverage,
29
- :stream_coverage,
30
- :provider_response_id_coverage)
31
10
 
32
11
  class << self
33
12
  def call(scope: LlmCostTracker::Call.all)
34
- 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"
35
22
  end
36
23
 
37
24
  def quarantined_inbox
38
- return nil unless Ingestion.async?
39
- return nil unless Ingestion::InboxEntry.table_exists?
25
+ return nil unless Ingestion.async? && Ingestion::InboxEntry.table_exists?
40
26
 
41
27
  row = Ingestion::InboxEntry
42
28
  .quarantined
@@ -47,53 +33,10 @@ module LlmCostTracker
47
33
 
48
34
  def unseen_budget_tags
49
35
  budgeted = Budget::PerTag.configured
50
- return nil if budgeted.empty?
51
- return nil unless Budget::PerTag.columns?
52
- return nil unless LlmCostTracker::CallTag.exists?
36
+ return nil if budgeted.empty? || !Budget::PerTag.columns? || !LlmCostTracker::CallTag.exists?
53
37
 
54
38
  unseen = budgeted.keys.reject { |key| LlmCostTracker::CallTag.where(key: key).where.not(value: "").exists? }
55
- return nil if unseen.empty?
56
-
57
- UnseenBudgetTags.new(keys: unseen)
58
- end
59
-
60
- def summary(stats)
61
- total = stats.total_calls.to_i
62
- unknown_pricing_count = stats.unknown_pricing_count.to_i
63
- untagged_calls_count = stats.untagged_calls_count.to_i
64
- missing_latency_count = stats.missing_latency_count.to_i
65
- streaming_count = stats.streaming_count.to_i
66
- streaming_missing_usage = stats.streaming_missing_usage_count.to_i
67
- missing_provider_response_id_count = stats.missing_provider_response_id_count.to_i
68
- calls_with_pricing = total - unknown_pricing_count
69
- tagged_calls = total - untagged_calls_count
70
- calls_with_latency = total - missing_latency_count
71
- streams_with_usage = streaming_count - streaming_missing_usage
72
- calls_with_provider_response_id = total - missing_provider_response_id_count
73
-
74
- Summary.new(
75
- total,
76
- unknown_pricing_count,
77
- untagged_calls_count,
78
- missing_latency_count,
79
- streaming_count,
80
- streaming_missing_usage,
81
- calls_with_pricing,
82
- tagged_calls,
83
- calls_with_latency,
84
- streams_with_usage,
85
- calls_with_provider_response_id,
86
- percentage(unknown_pricing_count, total),
87
- percentage(untagged_calls_count, total),
88
- percentage(missing_latency_count, total),
89
- percentage(streaming_count, total),
90
- percentage(streaming_missing_usage, streaming_count),
91
- percentage(calls_with_pricing, total),
92
- percentage(tagged_calls, total),
93
- percentage(calls_with_latency, total),
94
- percentage(streams_with_usage, streaming_count),
95
- percentage(calls_with_provider_response_id, total)
96
- )
39
+ UnseenBudgetTags.new(keys: unseen) unless unseen.empty?
97
40
  end
98
41
 
99
42
  def unknown_pricing_by_model(scope, total_calls:)
@@ -107,101 +50,57 @@ module LlmCostTracker
107
50
  UnknownPricingRow.new(provider: row.provider,
108
51
  model: row.model,
109
52
  calls: calls,
110
- share_percent: percentage(calls, total_calls))
53
+ share_percent: Percent.of(calls, total_calls))
111
54
  end
112
55
  end
113
56
 
114
57
  def service_charge_rows(scope)
115
58
  call_table = LlmCostTracker::Call.quoted_table_name
116
- line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
117
- relation = LlmCostTracker::CallLineItem
118
- .where.not(unit: "token")
119
- .joins(:call)
120
- .merge(scope.unscope(:select, :order))
121
-
122
- relation
123
- .group("#{call_table}.provider", "#{line_item_table}.kind", "#{line_item_table}.cost_status")
124
- .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"))
125
67
  .select(
126
68
  "#{call_table}.provider AS provider",
127
- "#{line_item_table}.kind AS component",
128
- "#{line_item_table}.cost_status AS cost_status",
69
+ "#{item_table}.kind AS component",
70
+ "#{item_table}.cost_status AS cost_status",
129
71
  "COUNT(*) AS charges_count",
130
- "COALESCE(SUM(#{line_item_table}.quantity), 0) AS quantity",
131
- "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"
132
74
  )
133
75
  .limit(10)
134
76
  end
135
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
+
136
90
  def usage_rows(stats, component_costs: {})
137
91
  billable_tokens = stats.billable_tokens.to_f
138
-
139
92
  rows = Usage::Catalog.token_priced.map do |component|
140
93
  token_value = stats[component.token_key].to_i
141
-
142
94
  {
143
95
  token_key: component.token_key,
144
96
  cost_key: component.cost_key,
145
97
  token_value: token_value,
146
98
  cost_value: component_costs[component.key],
147
- share_percent: percentage(token_value, billable_tokens),
99
+ share_percent: Percent.of(token_value, billable_tokens),
148
100
  share_basis: nil
149
101
  }
150
102
  end
151
-
152
- rows + [
153
- {
154
- token_key: :hidden_output_tokens,
155
- cost_key: nil,
156
- token_value: stats.hidden_output_tokens.to_i,
157
- cost_value: nil,
158
- share_percent: stats.hidden_output_share.to_f,
159
- share_basis: :output
160
- }
161
- ]
162
- end
163
-
164
- def component_costs(scope)
165
- line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
166
- rows = LlmCostTracker::CallLineItem
167
- .where(unit: "token")
168
- .joins(:call)
169
- .merge(scope.unscope(:select, :order, :group))
170
- .group("#{line_item_table}.kind",
171
- "#{line_item_table}.direction",
172
- "#{line_item_table}.cache_state")
173
- .pluck(Arel.sql("#{line_item_table}.kind"),
174
- Arel.sql("#{line_item_table}.direction"),
175
- Arel.sql("#{line_item_table}.cache_state"),
176
- Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0)"))
177
- Usage::Catalog.costs_by_component(rows)
178
- end
179
-
180
- def streaming_health_rows(scope, total_streaming:)
181
- return [] unless total_streaming.positive?
182
-
183
- unknown_predicate = unknown_usage_source_predicate(scope)
184
- rows = scope.unscope(:select, :order, :group)
185
- .where(stream: true)
186
- .group(:provider)
187
- .order(Arel.sql("COUNT(*) DESC"), :provider)
188
- .pluck(
189
- :provider,
190
- Arel.sql("COUNT(*)"),
191
- Arel.sql("SUM(CASE WHEN #{unknown_predicate} THEN 1 ELSE 0 END)")
192
- )
193
-
194
- rows.map do |provider, streams, unknown|
195
- streams_count = streams.to_i
196
- unknown_count = unknown.to_i
197
- StreamingHealthRow.new(
198
- provider: provider,
199
- streams: streams_count,
200
- with_usage: streams_count - unknown_count,
201
- unknown: unknown_count,
202
- unknown_share: percentage(unknown_count, streams_count)
203
- )
204
- end
103
+ rows << hidden_output_usage_row(stats)
205
104
  end
206
105
 
207
106
  def hidden_output_summary(stats)
@@ -215,82 +114,39 @@ module LlmCostTracker
215
114
  }
216
115
  end
217
116
 
218
- private
219
-
220
- def percentage(numerator, denominator)
221
- return 0.0 unless denominator.positive?
222
-
223
- (numerator.to_f / denominator) * 100.0
224
- end
225
-
226
- def aggregate_selects(scope)
227
- unknown_pricing = Charges::CostStatus.unknown_pricing_sql
228
- selects = [
229
- "COUNT(*) AS total_calls",
230
- "#{conditional_count_sql(unknown_pricing)} AS unknown_pricing_count",
231
- "COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
232
- "#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
233
- "#{conditional_count_sql('stream')} AS streaming_count",
234
- "#{streaming_missing_usage_select(scope)} AS streaming_missing_usage_count",
235
- "#{provider_response_id_select} AS missing_provider_response_id_count"
236
- ]
237
-
238
- usage_sum_columns.each do |column|
239
- selects << "#{column_sum(scope, column)} AS #{column}"
240
- end
241
-
242
- selects << "#{billable_tokens_select(scope)} AS billable_tokens"
243
- selects << "#{hidden_output_share_select(scope)} AS hidden_output_share"
244
-
245
- selects.join(", ")
246
- end
247
-
248
- def usage_sum_columns
249
- Usage::Catalog.token_priced.map(&:token_key) + [:hidden_output_tokens]
250
- end
251
-
252
- def billable_tokens_select(scope)
253
- Usage::Catalog.token_priced
254
- .map { |component| column_sum(scope, component.token_key) }
255
- .join(" + ")
256
- end
257
-
258
- def hidden_output_share_select(scope)
259
- hidden_output = column_sum(scope, :hidden_output_tokens)
260
- output = column_sum(scope, :output_tokens)
261
-
262
- "CASE WHEN #{output} > 0 THEN #{hidden_output} * 100.0 / #{output} ELSE 0 END"
263
- end
264
-
265
- def unknown_usage_source_predicate(scope)
266
- quoted = scope.connection.quote(LlmCostTracker::Usage::Source::UNKNOWN)
267
- "usage_source = #{quoted} OR usage_source IS NULL"
268
- end
269
-
270
- def column_sum(scope, column)
271
- "COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)"
272
- end
117
+ def streaming_health_rows(scope, total_streaming:)
118
+ return [] unless total_streaming.positive?
273
119
 
274
- def conditional_count_sql(predicate)
275
- "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) }
276
127
  end
277
128
 
278
- def streaming_missing_usage_select(scope)
279
- conditional_count_sql("stream AND (#{unknown_usage_source_predicate(scope)})")
280
- end
129
+ private
281
130
 
282
- def provider_response_id_select
283
- predicate = "provider_response_id IS NULL OR provider_response_id = ''"
284
- 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
+ }
285
140
  end
286
141
 
287
- def tagged_calls_sql(scope)
288
- calls_table = scope.klass.quoted_table_name
289
- tags_table = LlmCostTracker::CallTag.quoted_table_name
290
-
291
- "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM #{tags_table} " \
292
- "WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id " \
293
- "AND #{tags_table}.#{scope.connection.quote_column_name('value')} != '') 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
+ )
294
150
  end
295
151
  end
296
152
  end
@@ -11,6 +11,7 @@ module LlmCostTracker
11
11
  ["Streaming only", "yes"],
12
12
  ["Non-streaming only", "no"]
13
13
  ].freeze
14
+ STREAM_FILTER_LABELS = { "yes" => "Streaming", "no" => "Non-streaming" }.freeze
14
15
 
15
16
  class << self
16
17
  def call(scope: LlmCostTracker::Call.all, params: {}, tags: {})
@@ -90,7 +91,7 @@ module LlmCostTracker
90
91
 
91
92
  def matchable?(values)
92
93
  values.none? { |value| value.include?("\0") } ||
93
- !LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.connection)
94
+ !LlmCostTracker::Ledger::Schema::Adapter.postgresql?(scope.lease_connection)
94
95
  end
95
96
 
96
97
  def normalized_string(value, name)
@@ -2,29 +2,22 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Dashboard
5
- module MonthlyBudget
6
- module_function
7
-
8
- def status
5
+ class MonthlyBudget
6
+ def self.status
9
7
  budget = LlmCostTracker.configuration.budgets.monthly
10
8
  return nil unless budget
11
9
 
12
- budget = budget.to_f
13
- now = Time.now.utc
14
- month_start = now.beginning_of_month
15
- month_end = now.end_of_month
16
- spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
17
- elapsed_seconds = now - month_start
18
- total_seconds = month_end - month_start
19
- projected_spent = if spent.zero? || !elapsed_seconds.positive?
20
- spent
21
- else
22
- spent * (total_seconds / elapsed_seconds)
23
- end
24
- percent_used = budget.positive? ? (spent / budget) * 100.0 : 0.0
25
- projected_percent_used = budget.positive? ? (projected_spent / budget) * 100.0 : 0.0
26
- projected_delta = projected_spent - budget
10
+ new(budget.to_f, Time.now.utc).status
11
+ end
27
12
 
13
+ def initialize(budget, now)
14
+ @budget = budget
15
+ @now = now
16
+ @spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
17
+ @projected_spent = project(@spent)
18
+ end
19
+
20
+ def status
28
21
  {
29
22
  budget: budget,
30
23
  spent: spent,
@@ -32,31 +25,45 @@ module LlmCostTracker
32
25
  projected_spent: projected_spent,
33
26
  projected_percent_used: projected_percent_used,
34
27
  projected_delta: projected_delta,
35
- projection_end_label: month_end.strftime("%b %-d"),
36
- fill_modifier: fill_modifier(percent_used),
37
- progress_percent: clamped_percent(percent_used),
38
- projected_marker_percent: clamped_percent(projected_percent_used),
39
- projected_delta_amount: projected_delta.abs,
40
- projected_delta_direction: projected_delta.positive? ? "over" : "under",
41
- projected_delta_status_class: projected_delta_status_class(projected_delta)
28
+ projection_end_label: now.end_of_month.strftime("%b %-d"),
29
+ fill_modifier: fill_modifier,
30
+ progress_percent: percent_used.clamp(0.0, 100.0),
31
+ projected_marker_percent: projected_percent_used.clamp(0.0, 100.0),
32
+ **projected_delta_labels
42
33
  }
43
34
  end
44
35
 
45
- def clamped_percent(value)
46
- value.clamp(0.0, 100.0)
36
+ private
37
+
38
+ attr_reader :budget, :now, :spent, :projected_spent
39
+
40
+ def project(amount)
41
+ month_start = now.beginning_of_month
42
+ elapsed_seconds = now - month_start
43
+ return amount if amount.zero? || !elapsed_seconds.positive?
44
+
45
+ amount * ((now.end_of_month - month_start) / elapsed_seconds)
47
46
  end
48
47
 
49
- def fill_modifier(percent)
50
- return "lct-budget-fill--over" if percent >= 100.0
51
- return "lct-budget-fill--warn" if percent >= 80.0
48
+ def percent_used = share_of_budget(spent)
49
+ def projected_percent_used = share_of_budget(projected_spent)
50
+ def share_of_budget(amount) = budget.positive? ? (amount / budget) * 100.0 : 0.0
51
+ def projected_delta = projected_spent - budget
52
+
53
+ def fill_modifier
54
+ return "lct-budget-fill--over" if percent_used >= 100.0
55
+ return "lct-budget-fill--warn" if percent_used >= 80.0
52
56
 
53
57
  ""
54
58
  end
55
59
 
56
- def projected_delta_status_class(delta)
57
- return "lct-budget-projection-status--over" if delta.positive?
58
-
59
- "lct-budget-projection-status--under"
60
+ def projected_delta_labels
61
+ direction = projected_delta.positive? ? "over" : "under"
62
+ {
63
+ projected_delta_amount: projected_delta.abs,
64
+ projected_delta_direction: direction,
65
+ projected_delta_status_class: "lct-budget-projection-status--#{direction}"
66
+ }
60
67
  end
61
68
  end
62
69
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module Percent
6
+ def self.of(part, whole)
7
+ whole = whole.to_f
8
+ whole.positive? ? (part.to_f / whole) * 100.0 : 0.0
9
+ end
10
+ end
11
+ end
12
+ end
@@ -4,6 +4,15 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  module Sort
6
6
  DIRECTIONS = %w[asc desc].freeze
7
+
8
+ Choice = Data.define(:column, :direction)
9
+
10
+ def self.resolve(column, direction, natural_directions:, fallback:)
11
+ column = natural_directions.key?(column.to_s) ? column.to_s : fallback
12
+ direction = direction.to_s
13
+ direction = natural_directions.fetch(column) unless DIRECTIONS.include?(direction)
14
+ Choice.new(column: column, direction: direction)
15
+ end
7
16
  end
8
17
  end
9
18
  end
@@ -28,18 +28,14 @@ module LlmCostTracker
28
28
  attr_reader :scope, :from, :to
29
29
 
30
30
  def alerts
31
- window_days = WINDOW_DAYS.to_f
32
- daily_spend_by_model.each_with_object([]) do |((provider, model), daily_costs), rows|
31
+ daily_spend_by_model.filter_map do |(provider, model), daily_costs|
33
32
  latest_spend = daily_costs.fetch(to, 0.0)
34
33
  next unless latest_spend.positive?
35
34
 
36
- baseline_days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
37
- mean = baseline_days.sum / window_days
38
- variance = baseline_days.sum { |value| (value - mean)**2 } / window_days
39
- threshold = mean + (2 * Math.sqrt(variance))
40
- next unless latest_spend > threshold
35
+ mean, deviation = baseline(daily_costs)
36
+ next unless latest_spend > mean + (2 * deviation)
41
37
 
42
- rows << {
38
+ {
43
39
  provider: provider,
44
40
  model: model,
45
41
  day: to,
@@ -50,22 +46,28 @@ module LlmCostTracker
50
46
  end
51
47
  end
52
48
 
53
- def daily_spend_by_model
54
- window = (to - WINDOW_DAYS).beginning_of_day..to.end_of_day
49
+ def baseline(daily_costs)
50
+ days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
51
+ mean = days.sum / WINDOW_DAYS.to_f
52
+ variance = days.sum { |value| (value - mean)**2 } / WINDOW_DAYS.to_f
53
+ [mean, Math.sqrt(variance)]
54
+ end
55
55
 
56
- grouped = Hash.new { |hash, key| hash[key] = Hash.new(0.0) }
56
+ def daily_spend_by_model
57
+ daily_costs = Hash.new { |hash, key| hash[key] = Hash.new(0.0) }
58
+ daily_totals.each do |(provider, model, day), total_cost|
59
+ daily_costs[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
60
+ end
61
+ daily_costs
62
+ end
57
63
 
64
+ def daily_totals
58
65
  scope
59
- .where(tracked_at: window)
66
+ .where(tracked_at: (to - WINDOW_DAYS).beginning_of_day..to.end_of_day)
60
67
  .where.not(total_cost: nil)
61
68
  .group(:provider, :model)
62
69
  .group_by_period(:day, time_zone: Time.zone)
63
70
  .sum(:total_cost)
64
- .each do |(provider, model, day), total_cost|
65
- grouped[[provider, model]][Date.iso8601(day.to_s)] += total_cost.to_f
66
- end
67
-
68
- grouped
69
71
  end
70
72
  end
71
73
  end