llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  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 +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  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 +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  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 +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "llm_cost_tracker/ledger/schema/adapter"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Period
8
+ module Grouping
9
+ PERIOD_FORMATS = {
10
+ day: {
11
+ postgres: "YYYY-MM-DD",
12
+ mysql: "%Y-%m-%d"
13
+ },
14
+ month: {
15
+ postgres: "YYYY-MM",
16
+ mysql: "%Y-%m"
17
+ }
18
+ }.freeze
19
+
20
+ private_constant :PERIOD_FORMATS
21
+
22
+ def group_by_period(period, column: :tracked_at)
23
+ group(Arel.sql(period_group_expression(period, column: column)))
24
+ end
25
+
26
+ def daily_costs(days: 30)
27
+ where(tracked_at: days.days.ago..)
28
+ .group_by_period(:day)
29
+ .sum(:total_cost)
30
+ end
31
+
32
+ private
33
+
34
+ def period_group_expression(period, column:)
35
+ period = validated_period(period)
36
+ column = period_column_expression(column)
37
+ formats = PERIOD_FORMATS.fetch(period)
38
+
39
+ if Ledger::Schema::Adapter.postgresql?(connection)
40
+ postgres_period_expression(period, column, formats)
41
+ elsif Ledger::Schema::Adapter.mysql?(connection)
42
+ "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
43
+ else
44
+ Ledger::Schema::Adapter.ensure_supported!(connection)
45
+ end
46
+ end
47
+
48
+ def postgres_period_expression(period, column, formats)
49
+ "TO_CHAR(" \
50
+ "DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
51
+ "#{connection.quote(formats.fetch(:postgres))}" \
52
+ ")"
53
+ end
54
+
55
+ def validated_period(period)
56
+ normalized_period = period.try(:to_sym)
57
+ return normalized_period if PERIOD_FORMATS.key?(normalized_period)
58
+
59
+ raise ArgumentError, "invalid period: #{period.inspect}"
60
+ end
61
+
62
+ def period_column_expression(column)
63
+ column = column.to_s
64
+ return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
65
+
66
+ raise ArgumentError, "invalid period column: #{column.inspect}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Period
8
+ class Total < ActiveRecord::Base
9
+ self.table_name = "llm_cost_tracker_period_totals"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Tags
8
+ module Accessors
9
+ def parsed_tags
10
+ return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
11
+
12
+ JSON.parse(tags || "{}")
13
+ rescue JSON::ParserError
14
+ {}
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,124 +1,141 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "llm_cost_tracker/pricing"
4
+ require "llm_cost_tracker/ledger/schema/adapter"
5
+
3
6
  module LlmCostTracker
4
7
  module Dashboard
5
- DataQualityStats = Data.define(
6
- :total_calls,
7
- :unknown_pricing_count,
8
- :untagged_calls_count,
9
- :missing_latency_count,
10
- :latency_column_present,
11
- :streaming_count,
12
- :streaming_missing_usage_count,
13
- :stream_column_present,
14
- :missing_provider_response_id_count,
15
- :provider_response_id_column_present,
16
- :usage_breakdown_column_present,
17
- :input_tokens,
18
- :cache_read_input_tokens,
19
- :cache_write_input_tokens,
20
- :output_tokens,
21
- :hidden_output_tokens,
22
- :input_cost,
23
- :cache_read_input_cost,
24
- :cache_write_input_cost,
25
- :output_cost,
26
- :unknown_pricing_by_model
27
- )
28
-
29
8
  class DataQuality
30
9
  class << self
31
- def call(scope: LlmCostTracker::LlmApiCall.all)
10
+ def call(scope: LlmCostTracker::Ledger::Call.all)
32
11
  model = scope.klass
33
- aggregates = DataQualityAggregate.call(scope: scope)
34
- total = aggregates.fetch(:total_calls).to_i
35
-
36
- DataQualityStats.new(
37
- total_calls: total,
38
- unknown_pricing_count: aggregates.fetch(:unknown_pricing_count).to_i,
39
- untagged_calls_count: total - aggregates.fetch(:tagged_calls_count).to_i,
40
- **latency_stats(aggregates, model:),
41
- **stream_stats(aggregates, model:),
42
- **provider_response_id_stats(aggregates, model:),
43
- **usage_stats(aggregates, model:),
44
- unknown_pricing_by_model: unknown_pricing_by_model(scope)
45
- )
12
+ scope.unscope(:order).select(aggregate_selects(scope, model:)).take
46
13
  end
47
14
 
48
- private
15
+ def unknown_pricing_by_model(scope)
16
+ scope.unknown_pricing
17
+ .group(:model)
18
+ .order(Arel.sql("COUNT(*) DESC"))
19
+ .select("model, COUNT(*) AS calls")
20
+ .limit(10)
21
+ end
49
22
 
50
- def latency_stats(aggregates, model:)
51
- latency_present = model.latency_column?
23
+ def usage_rows(stats)
24
+ billable_tokens = stats.billable_tokens.to_f
52
25
 
53
- {
54
- missing_latency_count: latency_present ? aggregates.fetch(:missing_latency_count).to_i : nil,
55
- latency_column_present: latency_present
56
- }
57
- end
26
+ rows = Pricing::COMPONENTS.map do |component|
27
+ token_key = component.token_key
28
+ cost_key = component.cost_key
29
+ token_value = stats[token_key].to_i
30
+ share_percent = if billable_tokens.positive?
31
+ (token_value.to_f / billable_tokens) * 100.0
32
+ else
33
+ 0.0
34
+ end
58
35
 
59
- def stream_stats(aggregates, model:)
60
- stream_present = model.stream_column?
61
- usage_source_present = model.usage_source_column?
62
- streaming_missing_usage_count = nil
63
- if stream_present && usage_source_present
64
- streaming_missing_usage_count = aggregates.fetch(:streaming_missing_usage_count).to_i
36
+ {
37
+ price_key: component.price_key,
38
+ token_key: token_key,
39
+ cost_key: cost_key,
40
+ token_value: token_value,
41
+ cost_value: stats[cost_key],
42
+ share_percent: share_percent,
43
+ share_basis: nil
44
+ }
65
45
  end
66
46
 
67
- {
68
- streaming_count: stream_present ? aggregates.fetch(:streaming_count).to_i : nil,
69
- streaming_missing_usage_count: streaming_missing_usage_count,
70
- stream_column_present: stream_present
71
- }
47
+ rows + [
48
+ {
49
+ price_key: nil,
50
+ token_key: :hidden_output_tokens,
51
+ cost_key: nil,
52
+ token_value: stats.hidden_output_tokens.to_i,
53
+ cost_value: nil,
54
+ share_percent: stats.hidden_output_share.to_f,
55
+ share_basis: :output
56
+ }
57
+ ]
72
58
  end
73
59
 
74
- def provider_response_id_stats(aggregates, model:)
75
- column_present = model.provider_response_id_column?
76
- missing_provider_response_id_count = nil
77
- if column_present
78
- missing_provider_response_id_count = aggregates.fetch(:missing_provider_response_id_count).to_i
79
- end
60
+ def hidden_output_summary(stats)
61
+ output_tokens = stats.output_tokens.to_i
62
+ return unless output_tokens.positive?
80
63
 
81
64
  {
82
- missing_provider_response_id_count: missing_provider_response_id_count,
83
- provider_response_id_column_present: column_present
65
+ hidden_output_tokens: stats.hidden_output_tokens.to_i,
66
+ output_tokens: output_tokens,
67
+ share_percent: stats.hidden_output_share.to_f
84
68
  }
85
69
  end
86
70
 
87
- def usage_stats(aggregates, model:)
88
- usage_breakdown_present = model.usage_breakdown_columns?
89
- usage_breakdown_cost_present = model.usage_breakdown_cost_columns?
90
- cache_read_input_cost = nil
91
- cache_write_input_cost = nil
92
- if usage_breakdown_cost_present
93
- cache_read_input_cost = decimal_sum(aggregates.fetch(:cache_read_input_cost))
94
- cache_write_input_cost = decimal_sum(aggregates.fetch(:cache_write_input_cost))
71
+ private
72
+
73
+ def aggregate_selects(scope, model:)
74
+ selects = [
75
+ "COUNT(*) AS total_calls",
76
+ "#{conditional_count_sql('total_cost IS NULL')} AS unknown_pricing_count",
77
+ "#{tagged_calls_sql(model)} AS tagged_calls_count",
78
+ "COUNT(*) - #{tagged_calls_sql(model)} AS untagged_calls_count",
79
+ "#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
80
+ "#{conditional_count_sql('stream')} AS streaming_count",
81
+ "#{streaming_missing_usage_select} AS streaming_missing_usage_count",
82
+ "#{provider_response_id_select} AS missing_provider_response_id_count"
83
+ ]
84
+
85
+ usage_sum_columns.each do |column|
86
+ selects << "#{column_sum(scope, column)} AS #{column}"
95
87
  end
96
88
 
97
- {
98
- usage_breakdown_column_present: usage_breakdown_present,
99
- input_tokens: aggregates.fetch(:input_tokens).to_i,
100
- cache_read_input_tokens: usage_breakdown_present ? aggregates.fetch(:cache_read_input_tokens).to_i : nil,
101
- cache_write_input_tokens: usage_breakdown_present ? aggregates.fetch(:cache_write_input_tokens).to_i : nil,
102
- output_tokens: aggregates.fetch(:output_tokens).to_i,
103
- hidden_output_tokens: usage_breakdown_present ? aggregates.fetch(:hidden_output_tokens).to_i : nil,
104
- input_cost: decimal_sum(aggregates.fetch(:input_cost)),
105
- cache_read_input_cost: cache_read_input_cost,
106
- cache_write_input_cost: cache_write_input_cost,
107
- output_cost: decimal_sum(aggregates.fetch(:output_cost))
108
- }
89
+ selects << "#{billable_tokens_select(scope)} AS billable_tokens"
90
+ selects << "#{hidden_output_share_select(scope)} AS hidden_output_share"
91
+
92
+ selects.join(", ")
109
93
  end
110
94
 
111
- def unknown_pricing_by_model(scope)
112
- scope.unknown_pricing
113
- .group(:model)
114
- .order(Arel.sql("COUNT(*) DESC"))
115
- .count
116
- .first(10)
117
- .to_h
95
+ def usage_sum_columns
96
+ Pricing::COMPONENTS.map(&:token_key) + [:hidden_output_tokens] + Pricing::COMPONENTS.map(&:cost_key)
97
+ end
98
+
99
+ def billable_tokens_select(scope)
100
+ Pricing::COMPONENTS
101
+ .map { |component| column_sum(scope, component.token_key) }
102
+ .join(" + ")
118
103
  end
119
104
 
120
- def decimal_sum(value)
121
- value.to_f.round(8)
105
+ def hidden_output_share_select(scope)
106
+ hidden_output = column_sum(scope, :hidden_output_tokens)
107
+ output = column_sum(scope, :output_tokens)
108
+
109
+ "CASE WHEN #{output} > 0 THEN #{hidden_output} * 100.0 / #{output} ELSE 0 END"
110
+ end
111
+
112
+ def column_sum(scope, column)
113
+ "COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)"
114
+ end
115
+
116
+ def conditional_count_sql(predicate)
117
+ "COALESCE(SUM(CASE WHEN #{predicate} THEN 1 ELSE 0 END), 0)"
118
+ end
119
+
120
+ def streaming_missing_usage_select
121
+ predicate = "stream AND (usage_source = 'unknown' OR usage_source IS NULL)"
122
+ conditional_count_sql(predicate)
123
+ end
124
+
125
+ def provider_response_id_select
126
+ predicate = "provider_response_id IS NULL OR provider_response_id = ''"
127
+ conditional_count_sql(predicate)
128
+ end
129
+
130
+ def tagged_calls_sql(model)
131
+ table = model.quoted_table_name
132
+ column = "#{table}.#{model.connection.quote_column_name('tags')}"
133
+
134
+ if Ledger::Schema::Adapter.postgresql?(model.connection)
135
+ "COALESCE(SUM(CASE WHEN #{column} <> '{}'::jsonb THEN 1 ELSE 0 END), 0)"
136
+ else
137
+ "COALESCE(SUM(CASE WHEN JSON_LENGTH(#{column}) > 0 THEN 1 ELSE 0 END), 0)"
138
+ end
122
139
  end
123
140
  end
124
141
  end
@@ -13,8 +13,8 @@ module LlmCostTracker
13
13
  end
14
14
 
15
15
  def self.parse(params, key)
16
- value = LlmCostTracker::ParameterHash.with_indifferent_access(params)[key].to_s.strip
17
- return nil if value.empty?
16
+ value = LlmCostTracker::Dashboard::Params.with_indifferent_access(params)[key].to_s.strip.presence
17
+ return nil unless value
18
18
 
19
19
  Date.iso8601(value)
20
20
  rescue ArgumentError
@@ -6,14 +6,14 @@ module LlmCostTracker
6
6
  module Dashboard
7
7
  class Filter
8
8
  class << self
9
- def call(scope: LlmCostTracker::LlmApiCall.all, params: {})
9
+ def call(scope: LlmCostTracker::Ledger::Call.all, params: {})
10
10
  new(scope: scope, params: params).relation
11
11
  end
12
12
  end
13
13
 
14
14
  def initialize(scope:, params:)
15
15
  @scope = scope
16
- @params = LlmCostTracker::ParameterHash.with_indifferent_access(params)
16
+ @params = LlmCostTracker::Dashboard::Params.with_indifferent_access(params)
17
17
  end
18
18
 
19
19
  def relation
@@ -31,8 +31,8 @@ module LlmCostTracker
31
31
  attr_reader :scope, :params
32
32
 
33
33
  def apply_date_filters(relation)
34
- from_date = parse_date(:from)
35
- to_date = parse_date(:to)
34
+ from_date = Dashboard::DateRange.parse(params, :from)
35
+ to_date = Dashboard::DateRange.parse(params, :to)
36
36
  Dashboard::DateRange.validate!(from: from_date, to: to_date)
37
37
 
38
38
  from = from_date&.beginning_of_day
@@ -59,7 +59,6 @@ module LlmCostTracker
59
59
  def apply_stream_filter(relation)
60
60
  value = normalized_string(params[:stream])
61
61
  return relation if value.nil?
62
- return relation unless relation.klass.stream_column?
63
62
 
64
63
  case value.downcase
65
64
  when "yes", "true", "1" then relation.where(stream: true)
@@ -71,35 +70,25 @@ module LlmCostTracker
71
70
  def apply_usage_source_filter(relation)
72
71
  value = normalized_string(params[:usage_source])
73
72
  return relation if value.nil?
74
- return relation unless relation.klass.usage_source_column?
75
73
 
76
74
  relation.where(usage_source: value)
77
75
  end
78
76
 
79
77
  def tag_params
80
- tags = hash_param(:tag)
78
+ tags = LlmCostTracker::Dashboard::Params.to_hash(params[:tag])
81
79
 
82
80
  tags.each_with_object({}) do |(key, value), normalized|
83
81
  value = normalized_string(value)
84
82
  next if value.nil?
85
83
 
86
- normalized[LlmCostTracker::TagKey.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
84
+ normalized[LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
87
85
  end
88
86
  end
89
87
 
90
- def hash_param(key)
91
- LlmCostTracker::ParameterHash.to_hash(params[key])
92
- end
93
-
94
- def parse_date(key)
95
- Dashboard::DateRange.parse(params, key)
96
- end
97
-
98
88
  def normalized_string(value)
99
89
  return nil if value.nil?
100
90
 
101
- value = value.to_s.strip
102
- value.empty? ? nil : value
91
+ value.to_s.strip.presence
103
92
  end
104
93
  end
105
94
  end
@@ -1,85 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/storage/active_record_store"
3
+ require "llm_cost_tracker/ledger"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Dashboard
7
- OverviewStatsData = Data.define(
8
- :total_cost,
9
- :total_calls,
10
- :average_cost_per_call,
11
- :average_latency_ms,
12
- :unknown_pricing_count,
13
- :previous_total_cost,
14
- :previous_total_calls,
15
- :cost_delta_percent,
16
- :calls_delta_percent,
17
- :monthly_budget_status
18
- )
19
-
20
7
  class OverviewStats
21
8
  class << self
22
- def call(scope: LlmCostTracker::LlmApiCall.all, previous_scope: nil)
23
- current = aggregate(scope)
24
- total_calls = current.calls_count.to_i
25
- total_cost = current.total_cost_sum.to_f
26
-
27
- previous = previous_scope && aggregate(previous_scope)
28
- prev_cost = previous&.total_cost_sum.to_f
29
- prev_calls = previous&.calls_count.to_i
30
-
31
- OverviewStatsData.new(
32
- total_cost: total_cost,
33
- total_calls: total_calls,
34
- average_cost_per_call: total_calls.positive? ? total_cost / total_calls : 0.0,
35
- average_latency_ms: latency_value(current, scope),
36
- unknown_pricing_count: current.unknown_pricing_count.to_i,
37
- previous_total_cost: previous ? prev_cost : nil,
38
- previous_total_calls: previous ? prev_calls : nil,
39
- cost_delta_percent: previous ? delta_percent(total_cost, prev_cost) : nil,
40
- calls_delta_percent: previous ? delta_percent(total_calls, prev_calls) : nil,
41
- monthly_budget_status: budget_status
42
- )
43
- end
44
-
45
- private
46
-
47
- def aggregate(scope)
48
- scope.select(aggregate_selects(scope)).take
49
- end
50
-
51
- def aggregate_selects(scope)
52
- selects = [
53
- "COUNT(*) AS calls_count",
54
- "COALESCE(SUM(total_cost), 0) AS total_cost_sum",
55
- "SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END) AS unknown_pricing_count"
56
- ]
57
- selects << "AVG(latency_ms) AS average_latency" if scope.klass.latency_column?
58
- selects.join(", ")
9
+ def call(scope: LlmCostTracker::Ledger::Call.all, previous_scope: nil)
10
+ scope.select(aggregate_selects(previous_scope: previous_scope)).take
59
11
  end
60
12
 
61
- def latency_value(row, scope)
62
- return nil unless scope.klass.latency_column?
63
-
64
- row.average_latency&.to_f
65
- end
66
-
67
- def delta_percent(current, previous)
68
- current = current.to_f
69
- previous = previous.to_f
70
- return nil if previous.zero?
71
-
72
- ((current - previous) / previous) * 100.0
73
- end
74
-
75
- def budget_status
13
+ def monthly_budget_status
76
14
  budget = LlmCostTracker.configuration.monthly_budget
77
15
  return nil unless budget
78
16
 
79
17
  now = Time.now.utc
80
18
  month_start = now.beginning_of_month
81
19
  month_end = now.end_of_month
82
- spent = LlmCostTracker::Storage::ActiveRecordStore.monthly_total(time: now)
20
+ spent = LlmCostTracker::Ledger::Period::Totals.call(%i[monthly], time: now).fetch(:monthly)
83
21
  elapsed_seconds = now - month_start
84
22
  total_seconds = month_end - month_start
85
23
  projected_spent = if spent.zero? || !elapsed_seconds.positive?
@@ -98,6 +36,59 @@ module LlmCostTracker
98
36
  projection_end_label: month_end.strftime("%b %-d")
99
37
  }
100
38
  end
39
+
40
+ private
41
+
42
+ def aggregate_selects(previous_scope:)
43
+ average_cost_sql = <<~SQL.squish
44
+ CASE WHEN COUNT(*) > 0
45
+ THEN COALESCE(SUM(total_cost), 0) * 1.0 / COUNT(*)
46
+ ELSE 0 END
47
+ SQL
48
+ selects = [
49
+ "COUNT(*) AS total_calls",
50
+ "COALESCE(SUM(total_cost), 0) AS total_cost",
51
+ "#{average_cost_sql} AS average_cost_per_call",
52
+ "SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END) AS unknown_pricing_count",
53
+ "AVG(latency_ms) AS average_latency_ms"
54
+ ]
55
+ selects.concat(previous_selects(previous_scope))
56
+ selects.join(", ")
57
+ end
58
+
59
+ def previous_selects(previous_scope)
60
+ unless previous_scope
61
+ return [
62
+ "NULL AS previous_total_cost",
63
+ "NULL AS previous_total_calls",
64
+ "NULL AS cost_delta_percent",
65
+ "NULL AS calls_delta_percent"
66
+ ]
67
+ end
68
+
69
+ previous_cost_sql = aggregate_subquery(previous_scope, "COALESCE(SUM(total_cost), 0)")
70
+ previous_calls_sql = aggregate_subquery(previous_scope, "COUNT(*)")
71
+ cost_delta_sql = <<~SQL.squish
72
+ CASE WHEN (#{previous_cost_sql}) = 0 THEN NULL
73
+ ELSE ((COALESCE(SUM(total_cost), 0) - (#{previous_cost_sql})) * 100.0 / (#{previous_cost_sql}))
74
+ END
75
+ SQL
76
+ calls_delta_sql = <<~SQL.squish
77
+ CASE WHEN (#{previous_calls_sql}) = 0 THEN NULL
78
+ ELSE ((COUNT(*) - (#{previous_calls_sql})) * 100.0 / (#{previous_calls_sql}))
79
+ END
80
+ SQL
81
+ [
82
+ "(#{previous_cost_sql}) AS previous_total_cost",
83
+ "(#{previous_calls_sql}) AS previous_total_calls",
84
+ "#{cost_delta_sql} AS cost_delta_percent",
85
+ "#{calls_delta_sql} AS calls_delta_percent"
86
+ ]
87
+ end
88
+
89
+ def aggregate_subquery(scope, expression)
90
+ scope.unscope(:select, :order).select(expression).to_sql
91
+ end
101
92
  end
102
93
  end
103
94
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ class Pagination
6
+ DEFAULT_PER = 50
7
+ MAX_PER = 200
8
+ MIN_PAGE = 1
9
+
10
+ attr_reader :page, :per
11
+
12
+ def self.call(params)
13
+ params = Params.with_indifferent_access(params)
14
+ new(
15
+ page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
16
+ per: integer_param(params, :per, default: DEFAULT_PER, min: 1, max: MAX_PER)
17
+ )
18
+ end
19
+
20
+ def self.integer_param(params, key, default:, min:, max: nil)
21
+ value = Integer(params[key], 10)
22
+ value = [value, min].max
23
+ value = [value, max].min if max
24
+ value
25
+ rescue ArgumentError, TypeError
26
+ default
27
+ end
28
+ private_class_method :integer_param
29
+
30
+ def initialize(page:, per:)
31
+ @page = page
32
+ @per = per
33
+ freeze
34
+ end
35
+
36
+ def limit
37
+ per
38
+ end
39
+
40
+ def offset
41
+ (page - 1) * per
42
+ end
43
+
44
+ def prev_page?
45
+ page > MIN_PAGE
46
+ end
47
+
48
+ def next_page?(total_count)
49
+ offset + per < total_count.to_i
50
+ end
51
+
52
+ def total_pages(total_count)
53
+ return MIN_PAGE if total_count.to_i <= 0
54
+
55
+ [(total_count.to_f / per).ceil, MIN_PAGE].max
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module Params
6
+ class << self
7
+ def to_hash(value)
8
+ return {} if value.nil?
9
+
10
+ unsafe_hash = value.try(:to_unsafe_h)
11
+ return unsafe_hash if unsafe_hash.is_a?(Hash)
12
+ return value if value.is_a?(Hash)
13
+
14
+ hash = value.try(:to_h)
15
+ hash.is_a?(Hash) ? hash : {}
16
+ rescue ArgumentError, TypeError
17
+ {}
18
+ end
19
+
20
+ def with_indifferent_access(value)
21
+ to_hash(value).with_indifferent_access
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end