llm_cost_tracker 0.7.3 → 0.8.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/.ruby-version +1 -0
  3. data/CHANGELOG.md +66 -1
  4. data/README.md +58 -225
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +121 -30
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +96 -13
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +4 -10
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -15,7 +15,8 @@ module LlmCostTracker
15
15
  include TokenUsageHelper
16
16
 
17
17
  def coverage_percent(numerator, denominator)
18
- return 0.0 unless denominator.to_i.positive?
18
+ denominator = denominator.to_f
19
+ return 0.0 unless denominator.positive?
19
20
 
20
21
  (numerator.to_f / denominator) * 100.0
21
22
  end
@@ -39,16 +40,19 @@ module LlmCostTracker
39
40
  number_with_delimiter(value.to_i)
40
41
  end
41
42
 
42
- def format_tokens(value)
43
- number(value)
44
- end
45
-
46
43
  def format_date(value)
47
44
  value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
48
45
  end
49
46
 
50
47
  def pricing_status(call)
51
- call.total_cost.nil? ? "Unknown pricing" : "Estimated"
48
+ return "Unknown pricing" if call.total_cost.nil?
49
+ return "Estimated" unless call.has_attribute?(:cost_status)
50
+
51
+ {
52
+ LlmCostTracker::Billing::CostStatus::COMPLETE => "Estimated",
53
+ LlmCostTracker::Billing::CostStatus::FREE => "Free",
54
+ LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial pricing"
55
+ }.fetch(call.cost_status, "Unknown pricing")
52
56
  end
53
57
 
54
58
  def percent(value)
@@ -100,15 +104,6 @@ module LlmCostTracker
100
104
  value.to_s
101
105
  end
102
106
 
103
- def tags_summary(tags, limit: 3)
104
- tags = normalized_tags(tags)
105
- return "(untagged)" if tags.empty?
106
-
107
- summary = tags.first(limit).map { |key, value| "#{key}=#{tag_value_summary(value)}" }
108
- summary << "+#{tags.size - limit}" if tags.size > limit
109
- summary.join(", ")
110
- end
111
-
112
107
  def tag_chip_entries(tags, limit: 3)
113
108
  normalized = normalized_tags(tags)
114
109
  return [] if normalized.empty?
@@ -124,14 +119,6 @@ module LlmCostTracker
124
119
  truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
125
120
  end
126
121
 
127
- def budget_fill_modifier(percent)
128
- percent = percent.to_f
129
- return "lct-budget-fill--over" if percent >= 100.0
130
- return "lct-budget-fill--warn" if percent >= 80.0
131
-
132
- ""
133
- end
134
-
135
122
  def current_query(overrides = {})
136
123
  request.query_parameters.symbolize_keys.merge(overrides)
137
124
  end
@@ -164,7 +151,7 @@ module LlmCostTracker
164
151
  def truncate_text(string, limit)
165
152
  return string if string.bytesize <= limit
166
153
 
167
- "#{string.byteslice(0, limit).to_s.encode('UTF-8', invalid: :replace, undef: :replace)}..."
154
+ "#{string.byteslice(0, limit).encode('UTF-8', invalid: :replace, undef: :replace)}..."
168
155
  end
169
156
  end
170
157
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module DashboardFilterHelper
5
- FILTER_PARAM_KEYS = %i[from to provider model stream usage_source tag sort page per].freeze
5
+ FILTER_PARAM_KEYS = %i[from to provider model stream usage_source tag].freeze
6
6
 
7
7
  STREAM_FILTER_OPTIONS = [
8
8
  ["Streaming only", "yes"],
@@ -14,33 +14,15 @@ module LlmCostTracker
14
14
  end
15
15
 
16
16
  def active_tag_filters
17
- tag_params = LlmCostTracker::Dashboard::Params.to_hash(params[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
17
+ tag_params = LlmCostTracker::Dashboard::Params.tag_query(params[:tag])
18
18
 
19
19
  tag_params.filter_map do |key, value|
20
- next if key.blank? || value.blank?
21
-
22
20
  {
23
21
  label: "Tag",
24
22
  value: "#{key}=#{value}",
25
- path: dashboard_filter_path(current_query(tag: tag_params.except(key.to_s).presence, page: nil))
23
+ path: dashboard_filter_path(current_query(tag: tag_params.except(key).presence, page: nil))
26
24
  }
27
25
  end
28
26
  end
29
-
30
- def dashboard_date_range_label(from, to)
31
- from_label = short_date_label(from) || "Any time"
32
- to_label = short_date_label(to) || "Now"
33
- "#{from_label} - #{to_label}"
34
- end
35
-
36
- private
37
-
38
- def short_date_label(value)
39
- return nil if value.blank?
40
-
41
- Date.iso8601(value.to_s).strftime("%b %-d, %Y")
42
- rescue ArgumentError
43
- value.to_s
44
- end
45
27
  end
46
28
  end
@@ -15,14 +15,14 @@ module LlmCostTracker
15
15
  private
16
16
 
17
17
  def filter_options_for(column, filter_params:)
18
- source = LlmCostTracker::Dashboard::Params.to_hash(filter_params)
19
- scope_params = source.stringify_keys.merge(
20
- column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
18
+ source = LlmCostTracker::Dashboard::Params.to_hash(filter_params).symbolize_keys
19
+ scope_params = source.merge(
20
+ column => nil, format: nil, page: nil, per: nil, sort: nil
21
21
  )
22
22
  values = LlmCostTracker::Dashboard::Filter.call(params: scope_params)
23
23
  .where.not(column => [nil, ""])
24
24
  .distinct.order(column).limit(MAX_FILTER_OPTIONS).pluck(column)
25
- current = source[column.to_s].presence || source[column].presence
25
+ current = source[column].presence
26
26
  values.unshift(current) if current && !values.include?(current)
27
27
  values
28
28
  end
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
 
12
12
  def calls_query_for_tag(key:, value:)
13
13
  query = current_query(page: nil, per: nil, format: nil)
14
- tags = LlmCostTracker::Dashboard::Params.to_hash(query[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
14
+ tags = LlmCostTracker::Dashboard::Params.tag_query(query[:tag])
15
15
  query[:tag] = tags.merge(key.to_s => value.to_s)
16
16
  query
17
17
  end
@@ -6,22 +6,26 @@ module LlmCostTracker
6
6
  input_tokens: "Input",
7
7
  cache_read_input_tokens: "Cache read",
8
8
  cache_write_input_tokens: "Cache write",
9
- cache_write_1h_input_tokens: "1h cache write",
9
+ cache_write_extended_input_tokens: "Extended cache write",
10
+ audio_input_tokens: "Audio input",
10
11
  output_tokens: "Output",
12
+ audio_output_tokens: "Audio output",
11
13
  hidden_output_tokens: "Hidden output"
12
14
  }.freeze
13
15
  QUALITY_LABELS = COMPONENT_LABELS.merge(
14
16
  input_tokens: "Regular input",
15
17
  cache_read_input_tokens: "Cache read input",
16
18
  cache_write_input_tokens: "Cache write input",
17
- cache_write_1h_input_tokens: "1h cache write input"
19
+ cache_write_extended_input_tokens: "Extended cache write input"
18
20
  ).freeze
19
21
  STACK_CLASSES = {
20
22
  input_tokens: "lct-stack-fill-input",
21
23
  cache_read_input_tokens: "lct-stack-fill-cache-read",
22
24
  cache_write_input_tokens: "lct-stack-fill-cache-write",
23
- cache_write_1h_input_tokens: "lct-stack-fill-cache-write-1h",
24
- output_tokens: "lct-stack-fill-output"
25
+ cache_write_extended_input_tokens: "lct-stack-fill-cache-write-extended",
26
+ audio_input_tokens: "lct-stack-fill-audio-input",
27
+ output_tokens: "lct-stack-fill-output",
28
+ audio_output_tokens: "lct-stack-fill-audio-output"
25
29
  }.freeze
26
30
 
27
31
  def token_usage_stack_components
@@ -30,18 +34,26 @@ module LlmCostTracker
30
34
  end
31
35
  end
32
36
 
33
- def token_usage_quality_label(token_key)
34
- QUALITY_LABELS.fetch(token_key.to_sym)
37
+ def call_line_item_costs_by_component(call)
38
+ call.line_items.each_with_object({}) do |line_item, accumulator|
39
+ component = LlmCostTracker::Billing::Components::TOKEN_PRICED.find do |item|
40
+ item.kind.to_s == line_item.kind.to_s &&
41
+ item.direction.to_s == line_item.direction.to_s &&
42
+ item.cache_state.to_s == line_item.cache_state.to_s
43
+ end
44
+ accumulator[component.key] = line_item.cost if component && line_item.cost
45
+ end
35
46
  end
36
47
 
37
48
  private
38
49
 
39
50
  def token_usage_display_components(labels:)
40
- LlmCostTracker::Pricing::COMPONENTS.map do |component|
51
+ LlmCostTracker::Billing::Components::TOKEN_PRICED.map do |component|
41
52
  token_key = component.token_key
42
53
  {
43
54
  token_key: token_key,
44
55
  cost_key: component.cost_key,
56
+ price_key: component.key,
45
57
  label: labels.fetch(token_key),
46
58
  css_class: STACK_CLASSES[token_key]
47
59
  }
@@ -49,6 +61,7 @@ module LlmCostTracker
49
61
  {
50
62
  token_key: :hidden_output_tokens,
51
63
  cost_key: nil,
64
+ price_key: nil,
52
65
  label: labels.fetch(:hidden_output_tokens),
53
66
  css_class: nil
54
67
  }
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "securerandom"
5
+
6
+ require "llm_cost_tracker/billing/cost_status"
7
+ require "llm_cost_tracker/ledger/schema/adapter"
8
+ require "llm_cost_tracker/ledger/tags/sql"
9
+
10
+ module LlmCostTracker
11
+ class Call < ActiveRecord::Base
12
+ self.table_name = "llm_cost_tracker_calls"
13
+
14
+ before_validation :assign_event_id
15
+
16
+ PERIOD_FORMATS = {
17
+ day: {
18
+ postgres: "YYYY-MM-DD",
19
+ mysql: "%Y-%m-%d"
20
+ },
21
+ month: {
22
+ postgres: "YYYY-MM",
23
+ mysql: "%Y-%m"
24
+ }
25
+ }.freeze
26
+
27
+ private_constant :PERIOD_FORMATS
28
+
29
+ scope :with_cost, -> { where.not(total_cost: nil) }
30
+ scope :without_cost, -> { where(total_cost: nil) }
31
+ scope :unknown_pricing, lambda {
32
+ where(total_cost: nil).or(
33
+ where(cost_status: [Billing::CostStatus::UNKNOWN, Billing::CostStatus::PARTIAL])
34
+ )
35
+ }
36
+ scope :with_latency, -> { where.not(latency_ms: nil) }
37
+ scope :streaming, -> { where(stream: true) }
38
+ scope :non_streaming, -> { where(stream: [false, nil]) }
39
+ scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
40
+ scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
41
+ scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
42
+ scope :streaming_missing_usage, lambda {
43
+ where(stream: true).where(usage_source: ["unknown", nil])
44
+ }
45
+
46
+ has_many :line_items,
47
+ class_name: "LlmCostTracker::CallLineItem",
48
+ foreign_key: :llm_cost_tracker_call_id,
49
+ inverse_of: :call,
50
+ dependent: :delete_all
51
+
52
+ has_many :tag_records,
53
+ class_name: "LlmCostTracker::CallTag",
54
+ foreign_key: :llm_cost_tracker_call_id,
55
+ inverse_of: :call,
56
+ dependent: :delete_all
57
+
58
+ scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
59
+ scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
60
+ scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
61
+ scope :between, ->(from, to) { where(tracked_at: from..to) }
62
+
63
+ class << self
64
+ def by_tag(key, value) = by_tags(key => value)
65
+
66
+ def by_tags(tags) = Ledger::Tags::Query.apply(tags)
67
+
68
+ def total_cost = sum(:total_cost).to_f
69
+
70
+ def total_tokens = sum(:total_tokens).to_i
71
+
72
+ def cost_by_model(limit: nil) = cost_by_column(:model, limit: limit)
73
+
74
+ def cost_by_provider(limit: nil) = cost_by_column(:provider, limit: limit)
75
+
76
+ def group_by_tag(key)
77
+ Ledger::Tags::Sql.join_relation(self, key).group(Ledger::Tags::Sql.value_arel)
78
+ end
79
+
80
+ def cost_by_tag(key, limit: nil)
81
+ label = Ledger::Tags::Sql.label_sql(connection)
82
+ raw_value = Ledger::Tags::Sql.raw_value_sql(connection)
83
+ relation = Ledger::Tags::Sql.join_relation(self, key)
84
+ .select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
85
+ .group(Arel.sql(label))
86
+ .order(
87
+ Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
88
+ Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
89
+ Arel.sql("#{label} DESC")
90
+ )
91
+ relation = relation.limit(limit) if limit
92
+ relation
93
+ end
94
+
95
+ def average_latency_ms = average(:latency_ms)&.to_f
96
+
97
+ def latency_by_model = group(:model).average(:latency_ms).transform_values(&:to_f)
98
+
99
+ def latency_by_provider = group(:provider).average(:latency_ms).transform_values(&:to_f)
100
+
101
+ def group_by_period(period, column: :tracked_at)
102
+ group(Arel.sql(period_group_expression(period, column: column)))
103
+ end
104
+
105
+ def daily_costs(days: 30)
106
+ where(tracked_at: days.days.ago..)
107
+ .group_by_period(:day)
108
+ .sum(:total_cost)
109
+ end
110
+
111
+ private
112
+
113
+ def cost_by_column(column, limit:)
114
+ quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
115
+ relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
116
+ .group(column)
117
+ .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
118
+ relation = relation.limit(limit) if limit
119
+ relation
120
+ end
121
+
122
+ def period_group_expression(period, column:)
123
+ period = validated_period(period)
124
+ column = period_column_expression(column)
125
+ formats = PERIOD_FORMATS.fetch(period)
126
+
127
+ if Ledger::Schema::Adapter.postgresql?(connection)
128
+ postgres_period_expression(period, column, formats)
129
+ elsif Ledger::Schema::Adapter.mysql?(connection)
130
+ "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
131
+ else
132
+ Ledger::Schema::Adapter.ensure_supported!(connection)
133
+ end
134
+ end
135
+
136
+ def postgres_period_expression(period, column, formats)
137
+ "TO_CHAR(" \
138
+ "DATE_TRUNC(#{connection.quote(period.name)}, #{column}), " \
139
+ "#{connection.quote(formats.fetch(:postgres))}" \
140
+ ")"
141
+ end
142
+
143
+ def validated_period(period)
144
+ return period if PERIOD_FORMATS.key?(period)
145
+
146
+ raise ArgumentError, "invalid period: #{period.inspect}"
147
+ end
148
+
149
+ def period_column_expression(column)
150
+ column = column.to_s
151
+ return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
152
+
153
+ raise ArgumentError, "invalid period column: #{column.inspect}"
154
+ end
155
+ end
156
+
157
+ def parsed_tags
158
+ tag_records.to_h do |record|
159
+ [record.key, record.value]
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def assign_event_id
166
+ self.event_id ||= SecureRandom.uuid
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class CallLineItem < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_call_line_items"
8
+
9
+ belongs_to :call,
10
+ class_name: "LlmCostTracker::Call",
11
+ foreign_key: :llm_cost_tracker_call_id,
12
+ inverse_of: :line_items
13
+
14
+ scope :tokens, -> { where("kind LIKE ?", "%_token") }
15
+ scope :by_kind, ->(kind) { where(kind: kind.to_s) }
16
+ scope :by_direction, ->(direction) { where(direction: direction.to_s) }
17
+ scope :by_modality, ->(modality) { where(modality: modality.to_s) }
18
+ scope :cached, -> { where.not(cache_state: ["none", nil]) }
19
+ scope :priced, -> { where(cost_status: %w[complete free]) }
20
+ scope :unpriced, -> { where(cost_status: "unknown") }
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class CallRollup < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_call_rollups"
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class CallTag < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_call_tags"
8
+
9
+ belongs_to :call,
10
+ class_name: "LlmCostTracker::Call",
11
+ foreign_key: :llm_cost_tracker_call_id,
12
+ inverse_of: :tag_records
13
+
14
+ scope :with_key, ->(key) { where(key: key.to_s) }
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ module Ingestion
7
+ class InboxEntry < ActiveRecord::Base
8
+ self.table_name = "llm_cost_tracker_ingestion_inbox_entries"
9
+
10
+ MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
11
+ end
12
+ end
13
+ end
@@ -5,7 +5,7 @@ require "active_record"
5
5
  module LlmCostTracker
6
6
  module Ingestion
7
7
  class Lease < ActiveRecord::Base
8
- self.table_name = "llm_cost_tracker_ingestor_leases"
8
+ self.table_name = "llm_cost_tracker_ingestion_leases"
9
9
  end
10
10
  end
11
11
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class ProviderInvoice < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_provider_invoices"
8
+ end
9
+ end