llm_cost_tracker 0.7.3 → 0.9.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class CallLineItem < ActiveRecord::Base
5
+ belongs_to :call,
6
+ class_name: "LlmCostTracker::Call",
7
+ foreign_key: :llm_cost_tracker_call_id,
8
+ inverse_of: :line_items
9
+
10
+ scope :tokens, -> { where(unit: "token") }
11
+ scope :by_kind, ->(kind) { where(kind: kind.to_s) }
12
+ scope :by_direction, ->(direction) { where(direction: direction.to_s) }
13
+ scope :by_modality, ->(modality) { where(modality: modality.to_s) }
14
+ scope :cached, -> { where.not(cache_state: ["none", nil]) }
15
+ scope :priced, -> { where(cost_status: %w[complete free]) }
16
+ scope :unpriced, -> { where(cost_status: "unknown") }
17
+ end
18
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class CallRollup < ActiveRecord::Base
5
+ end
6
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class CallTag < ActiveRecord::Base
5
+ belongs_to :call,
6
+ class_name: "LlmCostTracker::Call",
7
+ foreign_key: :llm_cost_tracker_call_id,
8
+ inverse_of: :tag_records
9
+
10
+ scope :with_key, ->(key) { where(key: key.to_s) }
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Ingestion
5
+ class InboxEntry < ActiveRecord::Base
6
+ MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
7
+ end
8
+ end
9
+ end
@@ -1,11 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record"
4
-
5
3
  module LlmCostTracker
6
4
  module Ingestion
7
5
  class Lease < ActiveRecord::Base
8
- self.table_name = "llm_cost_tracker_ingestor_leases"
9
6
  end
10
7
  end
11
8
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class ProviderInvoice < ActiveRecord::Base
5
+ before_validation :normalize_currency
6
+
7
+ private
8
+
9
+ def normalize_currency
10
+ self.currency = currency.to_s.upcase if currency.present?
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class ProviderInvoiceImport < ActiveRecord::Base
5
+ STATE_RUNNING = "running"
6
+ STATE_COMPLETED = "completed"
7
+ STATE_FAILED = "failed"
8
+ STATES = [STATE_RUNNING, STATE_COMPLETED, STATE_FAILED].freeze
9
+
10
+ scope :for_source, ->(source) { where(source: source.to_s) }
11
+ scope :running, -> { where(state: STATE_RUNNING) }
12
+ scope :completed, -> { where(state: STATE_COMPLETED) }
13
+ scope :failed, -> { where(state: STATE_FAILED) }
14
+ scope :latest, -> { order(started_at: :desc, id: :desc) }
15
+
16
+ def self.resume_cursor_for(source)
17
+ for_source(source).latest.limit(1).pick(:cursor)
18
+ end
19
+
20
+ def self.last_completed_window_for(source)
21
+ for_source(source).completed.latest.limit(1).pick(:window_start, :window_end)
22
+ end
23
+ end
24
+ end
@@ -1,44 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/pricing"
3
+ require "llm_cost_tracker/billing/components"
4
4
  require "llm_cost_tracker/ledger/schema/adapter"
5
5
 
6
6
  module LlmCostTracker
7
7
  module Dashboard
8
8
  class DataQuality
9
+ UnknownPricingRow = ::Data.define(:provider, :model, :calls, :share_percent)
10
+ StreamingHealthRow = ::Data.define(:provider, :streams, :with_usage, :unknown, :unknown_share)
11
+ Summary = ::Data.define(:total, :unknown_pricing_count, :untagged_calls_count, :missing_latency_count,
12
+ :streaming_count, :streaming_missing_usage, :missing_provider_response_id_count,
13
+ :calls_with_pricing, :tagged_calls, :calls_with_latency, :streams_with_usage,
14
+ :calls_with_provider_response_id, :unknown_pricing_share, :untagged_share,
15
+ :missing_latency_share, :streaming_share, :streaming_missing_usage_share,
16
+ :cost_coverage, :tag_coverage, :latency_coverage, :stream_coverage,
17
+ :provider_response_id_coverage)
18
+
9
19
  class << self
10
- def call(scope: LlmCostTracker::Ledger::Call.all)
20
+ def call(scope: LlmCostTracker::Call.all)
11
21
  scope.unscope(:order).select(aggregate_selects(scope)).take
12
22
  end
13
23
 
14
- def unknown_pricing_by_model(scope)
24
+ def summary(stats)
25
+ total = stats.total_calls.to_i
26
+ unknown_pricing_count = stats.unknown_pricing_count.to_i
27
+ untagged_calls_count = stats.untagged_calls_count.to_i
28
+ missing_latency_count = stats.missing_latency_count.to_i
29
+ streaming_count = stats.streaming_count.to_i
30
+ streaming_missing_usage = stats.streaming_missing_usage_count.to_i
31
+ missing_provider_response_id_count = stats.missing_provider_response_id_count.to_i
32
+ calls_with_pricing = total - unknown_pricing_count
33
+ tagged_calls = total - untagged_calls_count
34
+ calls_with_latency = total - missing_latency_count
35
+ streams_with_usage = streaming_count - streaming_missing_usage
36
+ calls_with_provider_response_id = total - missing_provider_response_id_count
37
+
38
+ Summary.new(
39
+ total, unknown_pricing_count, untagged_calls_count, missing_latency_count, streaming_count,
40
+ streaming_missing_usage, missing_provider_response_id_count, calls_with_pricing, tagged_calls,
41
+ calls_with_latency, streams_with_usage, calls_with_provider_response_id,
42
+ percentage(unknown_pricing_count, total), percentage(untagged_calls_count, total),
43
+ percentage(missing_latency_count, total), percentage(streaming_count, total),
44
+ percentage(streaming_missing_usage, streaming_count), percentage(calls_with_pricing, total),
45
+ percentage(tagged_calls, total), percentage(calls_with_latency, total),
46
+ percentage(streams_with_usage, streaming_count), percentage(calls_with_provider_response_id, total)
47
+ )
48
+ end
49
+
50
+ def unknown_pricing_by_model(scope, total_calls:)
15
51
  scope.unknown_pricing
16
- .group(:model)
52
+ .group(:provider, :model)
17
53
  .order(Arel.sql("COUNT(*) DESC"))
18
- .select("model, COUNT(*) AS calls")
54
+ .select("provider, model, COUNT(*) AS calls")
19
55
  .limit(10)
56
+ .map do |row|
57
+ calls = row.calls.to_i
58
+ UnknownPricingRow.new(provider: row.provider, model: row.model, calls: calls,
59
+ share_percent: percentage(calls, total_calls))
60
+ end
61
+ end
62
+
63
+ def service_charge_rows(scope)
64
+ call_table = LlmCostTracker::Call.quoted_table_name
65
+ line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
66
+ relation = LlmCostTracker::CallLineItem
67
+ .where.not(unit: "token")
68
+ .joins(:call)
69
+ .merge(scope.unscope(:select, :order))
70
+
71
+ relation
72
+ .group("#{call_table}.provider", "#{line_item_table}.kind", "#{line_item_table}.cost_status")
73
+ .order(Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0) DESC"), Arel.sql("COUNT(*) DESC"))
74
+ .select(
75
+ "#{call_table}.provider AS provider",
76
+ "#{line_item_table}.kind AS component",
77
+ "#{line_item_table}.cost_status AS cost_status",
78
+ "COUNT(*) AS charges_count",
79
+ "COALESCE(SUM(#{line_item_table}.quantity), 0) AS quantity",
80
+ "COALESCE(SUM(#{line_item_table}.cost), 0) AS total_cost"
81
+ )
82
+ .limit(10)
20
83
  end
21
84
 
22
- def usage_rows(stats)
85
+ def usage_rows(stats, component_costs: {})
23
86
  billable_tokens = stats.billable_tokens.to_f
24
87
 
25
- rows = Pricing::COMPONENTS.map do |component|
26
- token_key = component.token_key
27
- cost_key = component.cost_key
28
- token_value = stats[token_key].to_i
29
- share_percent = if billable_tokens.positive?
30
- (token_value.to_f / billable_tokens) * 100.0
31
- else
32
- 0.0
33
- end
88
+ rows = Billing::Components::TOKEN_PRICED.map do |component|
89
+ token_value = stats[component.token_key].to_i
34
90
 
35
91
  {
36
- price_key: component.price_key,
37
- token_key: token_key,
38
- cost_key: cost_key,
92
+ price_key: component.key,
93
+ token_key: component.token_key,
94
+ cost_key: component.cost_key,
39
95
  token_value: token_value,
40
- cost_value: stats[cost_key],
41
- share_percent: share_percent,
96
+ cost_value: component_costs[component.key],
97
+ share_percent: percentage(token_value, billable_tokens),
42
98
  share_basis: nil
43
99
  }
44
100
  end
@@ -56,6 +112,48 @@ module LlmCostTracker
56
112
  ]
57
113
  end
58
114
 
115
+ def component_costs(scope)
116
+ line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
117
+ rows = LlmCostTracker::CallLineItem
118
+ .where(unit: "token")
119
+ .joins(:call)
120
+ .merge(scope.unscope(:select, :order, :group))
121
+ .group("#{line_item_table}.kind", "#{line_item_table}.direction",
122
+ "#{line_item_table}.cache_state")
123
+ .pluck(Arel.sql("#{line_item_table}.kind"),
124
+ Arel.sql("#{line_item_table}.direction"),
125
+ Arel.sql("#{line_item_table}.cache_state"),
126
+ Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0)"))
127
+ index_costs_by_component(rows)
128
+ end
129
+
130
+ def streaming_health_rows(scope, total_streaming:)
131
+ return [] unless total_streaming.positive?
132
+
133
+ unknown_predicate = "usage_source = 'unknown' OR usage_source IS NULL"
134
+ rows = scope.unscope(:select, :order, :group)
135
+ .where(stream: true)
136
+ .group(:provider)
137
+ .order(Arel.sql("COUNT(*) DESC"), :provider)
138
+ .pluck(
139
+ :provider,
140
+ Arel.sql("COUNT(*)"),
141
+ Arel.sql("SUM(CASE WHEN #{unknown_predicate} THEN 1 ELSE 0 END)")
142
+ )
143
+
144
+ rows.map do |provider, streams, unknown|
145
+ streams_count = streams.to_i
146
+ unknown_count = unknown.to_i
147
+ StreamingHealthRow.new(
148
+ provider: provider,
149
+ streams: streams_count,
150
+ with_usage: streams_count - unknown_count,
151
+ unknown: unknown_count,
152
+ unknown_share: percentage(unknown_count, streams_count)
153
+ )
154
+ end
155
+ end
156
+
59
157
  def hidden_output_summary(stats)
60
158
  output_tokens = stats.output_tokens.to_i
61
159
  return unless output_tokens.positive?
@@ -69,10 +167,27 @@ module LlmCostTracker
69
167
 
70
168
  private
71
169
 
170
+ def index_costs_by_component(rows)
171
+ rows.each_with_object({}) do |(kind, direction, cache_state, cost), accumulator|
172
+ component = Billing::Components::TOKEN_PRICED.find do |item|
173
+ item.kind.to_s == kind.to_s &&
174
+ item.direction.to_s == direction.to_s &&
175
+ item.cache_state.to_s == cache_state.to_s
176
+ end
177
+ accumulator[component.key] = cost if component
178
+ end
179
+ end
180
+
181
+ def percentage(numerator, denominator)
182
+ return 0.0 unless denominator.positive?
183
+
184
+ (numerator.to_f / denominator) * 100.0
185
+ end
186
+
72
187
  def aggregate_selects(scope)
73
188
  selects = [
74
189
  "COUNT(*) AS total_calls",
75
- "#{conditional_count_sql('total_cost IS NULL')} AS unknown_pricing_count",
190
+ "#{conditional_count_sql(unknown_pricing_predicate(scope))} AS unknown_pricing_count",
76
191
  "#{tagged_calls_sql(scope)} AS tagged_calls_count",
77
192
  "COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
78
193
  "#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
@@ -92,11 +207,11 @@ module LlmCostTracker
92
207
  end
93
208
 
94
209
  def usage_sum_columns
95
- Pricing::COMPONENTS.map(&:token_key) + [:hidden_output_tokens] + Pricing::COMPONENTS.map(&:cost_key)
210
+ Billing::Components::TOKEN_PRICED.map(&:token_key) + [:hidden_output_tokens]
96
211
  end
97
212
 
98
213
  def billable_tokens_select(scope)
99
- Pricing::COMPONENTS
214
+ Billing::Components::TOKEN_PRICED
100
215
  .map { |component| column_sum(scope, component.token_key) }
101
216
  .join(" + ")
102
217
  end
@@ -108,6 +223,15 @@ module LlmCostTracker
108
223
  "CASE WHEN #{output} > 0 THEN #{hidden_output} * 100.0 / #{output} ELSE 0 END"
109
224
  end
110
225
 
226
+ def unknown_pricing_predicate(scope)
227
+ values = [
228
+ LlmCostTracker::Billing::CostStatus::UNKNOWN,
229
+ LlmCostTracker::Billing::CostStatus::PARTIAL
230
+ ].map { |value| scope.connection.quote(value) }
231
+
232
+ "total_cost IS NULL OR cost_status IN (#{values.join(', ')})"
233
+ end
234
+
111
235
  def column_sum(scope, column)
112
236
  "COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)"
113
237
  end
@@ -127,15 +251,11 @@ module LlmCostTracker
127
251
  end
128
252
 
129
253
  def tagged_calls_sql(scope)
130
- table = scope.klass.quoted_table_name
131
- connection = scope.connection
132
- column = "#{table}.#{connection.quote_column_name('tags')}"
133
-
134
- if Ledger::Schema::Adapter.postgresql?(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
254
+ calls_table = scope.klass.quoted_table_name
255
+ tags_table = LlmCostTracker::CallTag.quoted_table_name
256
+
257
+ "COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM #{tags_table} " \
258
+ "WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id) THEN 1 ELSE 0 END), 0)"
139
259
  end
140
260
  end
141
261
  end
@@ -13,7 +13,7 @@ module LlmCostTracker
13
13
  end
14
14
 
15
15
  def self.parse(params, key)
16
- value = LlmCostTracker::Dashboard::Params.with_indifferent_access(params)[key].to_s.strip.presence
16
+ value = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys[key].to_s.strip.presence
17
17
  return nil unless value
18
18
 
19
19
  Date.iso8601(value)
@@ -6,14 +6,14 @@ module LlmCostTracker
6
6
  module Dashboard
7
7
  class Filter
8
8
  class << self
9
- def call(scope: LlmCostTracker::Ledger::Call.all, params: {})
9
+ def call(scope: LlmCostTracker::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::Dashboard::Params.with_indifferent_access(params)
16
+ @params = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys
17
17
  end
18
18
 
19
19
  def relation
@@ -35,11 +35,13 @@ module LlmCostTracker
35
35
  to_date = Dashboard::DateRange.parse(params, :to)
36
36
  Dashboard::DateRange.validate!(from: from_date, to: to_date)
37
37
 
38
- from = from_date&.beginning_of_day
39
- to = to_date&.end_of_day
40
- relation = relation.where(tracked_at: from..) if from
41
- relation = relation.where(tracked_at: ..to) if to
38
+ default_range = Dashboard::DateRange.call(params: params)
39
+ from_date ||= default_range.from
40
+ to_date ||= default_range.to
41
+
42
42
  relation
43
+ .where(tracked_at: from_date.beginning_of_day..)
44
+ .where(tracked_at: ..to_date.end_of_day)
43
45
  end
44
46
 
45
47
  def apply_exact_filter(relation, key)
@@ -6,18 +6,25 @@ module LlmCostTracker
6
6
  module Dashboard
7
7
  class OverviewStats
8
8
  class << self
9
- def call(scope: LlmCostTracker::Ledger::Call.all, previous_scope: nil)
10
- scope.select(aggregate_selects(previous_scope: previous_scope)).take
9
+ def call(scope: LlmCostTracker::Call.all, previous_scope: nil)
10
+ return scope.select(aggregate_selects).take unless previous_scope
11
+
12
+ scope.klass
13
+ .from("(#{scope.unscope(:select, :order).to_sql}) AS current_calls")
14
+ .joins("CROSS JOIN (#{previous_aggregate_sql(previous_scope)}) AS previous_stats")
15
+ .select(aggregate_selects(table_name: "current_calls", previous: true))
16
+ .take
11
17
  end
12
18
 
13
19
  def monthly_budget_status
14
20
  budget = LlmCostTracker.configuration.monthly_budget
15
21
  return nil unless budget
16
22
 
23
+ budget = budget.to_f
17
24
  now = Time.now.utc
18
25
  month_start = now.beginning_of_month
19
26
  month_end = now.end_of_month
20
- spent = LlmCostTracker::Ledger::Period::Totals.call(%i[monthly], time: now).fetch(:monthly)
27
+ spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
21
28
  elapsed_seconds = now - month_start
22
29
  total_seconds = month_end - month_start
23
30
  projected_spent = if spent.zero? || !elapsed_seconds.positive?
@@ -25,39 +32,65 @@ module LlmCostTracker
25
32
  else
26
33
  spent * (total_seconds / elapsed_seconds)
27
34
  end
35
+ percent_used = budget.positive? ? (spent / budget) * 100.0 : 0.0
36
+ projected_percent_used = budget.positive? ? (projected_spent / budget) * 100.0 : 0.0
37
+ projected_delta = projected_spent - budget
28
38
 
29
39
  {
30
- budget: budget.to_f,
40
+ budget: budget,
31
41
  spent: spent,
32
- percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0,
42
+ percent_used: percent_used,
33
43
  projected_spent: projected_spent,
34
- projected_percent_used: budget.to_f.positive? ? (projected_spent / budget.to_f) * 100.0 : 0.0,
35
- projected_delta: projected_spent - budget.to_f,
36
- projection_end_label: month_end.strftime("%b %-d")
44
+ projected_percent_used: projected_percent_used,
45
+ projected_delta: projected_delta,
46
+ projection_end_label: month_end.strftime("%b %-d"),
47
+ fill_modifier: budget_fill_modifier(percent_used),
48
+ progress_percent: clamped_percent(percent_used),
49
+ projected_marker_percent: clamped_percent(projected_percent_used),
50
+ projected_delta_amount: projected_delta.abs,
51
+ projected_delta_direction: projected_delta.positive? ? "over" : "under",
52
+ projected_delta_status_class: projected_delta_status_class(projected_delta)
37
53
  }
38
54
  end
39
55
 
56
+ UNKNOWN_PRICING_COST_STATUSES = [
57
+ LlmCostTracker::Billing::CostStatus::UNKNOWN,
58
+ LlmCostTracker::Billing::CostStatus::PARTIAL
59
+ ].freeze
60
+
40
61
  private
41
62
 
42
- def aggregate_selects(previous_scope:)
63
+ def aggregate_selects(table_name: nil, previous: false)
64
+ total_cost = table_name ? "#{table_name}.total_cost" : "total_cost"
65
+ latency_ms = table_name ? "#{table_name}.latency_ms" : "latency_ms"
66
+ cost_status = table_name ? "#{table_name}.cost_status" : "cost_status"
43
67
  average_cost_sql = <<~SQL.squish
44
68
  CASE WHEN COUNT(*) > 0
45
- THEN COALESCE(SUM(total_cost), 0) * 1.0 / COUNT(*)
69
+ THEN COALESCE(SUM(#{total_cost}), 0) * 1.0 / COUNT(*)
46
70
  ELSE 0 END
47
71
  SQL
72
+ unknown_pricing_sql = <<~SQL.squish
73
+ SUM(CASE WHEN #{total_cost} IS NULL OR
74
+ #{cost_status} IN (#{UNKNOWN_PRICING_COST_STATUSES.map { |s| connection.quote(s) }.join(', ')})
75
+ THEN 1 ELSE 0 END)
76
+ SQL
48
77
  selects = [
49
78
  "COUNT(*) AS total_calls",
50
- "COALESCE(SUM(total_cost), 0) AS total_cost",
79
+ "COALESCE(SUM(#{total_cost}), 0) AS total_cost",
51
80
  "#{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"
81
+ "#{unknown_pricing_sql} AS unknown_pricing_count",
82
+ "AVG(#{latency_ms}) AS average_latency_ms"
54
83
  ]
55
- selects.concat(previous_selects(previous_scope))
84
+ selects.concat(previous_selects(previous))
56
85
  selects.join(", ")
57
86
  end
58
87
 
59
- def previous_selects(previous_scope)
60
- unless previous_scope
88
+ def connection
89
+ LlmCostTracker::Call.connection
90
+ end
91
+
92
+ def previous_selects(previous)
93
+ unless previous
61
94
  return [
62
95
  "NULL AS previous_total_cost",
63
96
  "NULL AS previous_total_calls",
@@ -66,11 +99,11 @@ module LlmCostTracker
66
99
  ]
67
100
  end
68
101
 
69
- previous_cost_sql = aggregate_subquery(previous_scope, "COALESCE(SUM(total_cost), 0)")
70
- previous_calls_sql = aggregate_subquery(previous_scope, "COUNT(*)")
102
+ previous_cost_sql = "MAX(previous_stats.total_cost)"
103
+ previous_calls_sql = "MAX(previous_stats.total_calls)"
71
104
  cost_delta_sql = <<~SQL.squish
72
105
  CASE WHEN (#{previous_cost_sql}) = 0 THEN NULL
73
- ELSE ((COALESCE(SUM(total_cost), 0) - (#{previous_cost_sql})) * 100.0 / (#{previous_cost_sql}))
106
+ ELSE ((COALESCE(SUM(current_calls.total_cost), 0) - (#{previous_cost_sql})) * 100.0 / (#{previous_cost_sql}))
74
107
  END
75
108
  SQL
76
109
  calls_delta_sql = <<~SQL.squish
@@ -86,8 +119,28 @@ module LlmCostTracker
86
119
  ]
87
120
  end
88
121
 
89
- def aggregate_subquery(scope, expression)
90
- scope.unscope(:select, :order).select(expression).to_sql
122
+ def previous_aggregate_sql(scope)
123
+ scope
124
+ .unscope(:select, :order)
125
+ .select("COALESCE(SUM(total_cost), 0) AS total_cost", "COUNT(*) AS total_calls")
126
+ .to_sql
127
+ end
128
+
129
+ def clamped_percent(value)
130
+ value.clamp(0.0, 100.0)
131
+ end
132
+
133
+ def budget_fill_modifier(percent)
134
+ return "lct-budget-fill--over" if percent >= 100.0
135
+ return "lct-budget-fill--warn" if percent >= 80.0
136
+
137
+ ""
138
+ end
139
+
140
+ def projected_delta_status_class(delta)
141
+ return "lct-budget-projection-status--over" if delta.positive?
142
+
143
+ "lct-budget-projection-status--under"
91
144
  end
92
145
  end
93
146
  end
@@ -10,7 +10,7 @@ module LlmCostTracker
10
10
  attr_reader :page, :per
11
11
 
12
12
  def self.call(params)
13
- params = Params.with_indifferent_access(params)
13
+ params = Params.to_hash(params).symbolize_keys
14
14
  new(
15
15
  page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
16
16
  per: integer_param(params, :per, default: DEFAULT_PER, min: 1, max: MAX_PER)
@@ -46,13 +46,15 @@ module LlmCostTracker
46
46
  end
47
47
 
48
48
  def next_page?(total_count)
49
- offset + per < total_count.to_i
49
+ total_count = total_count.to_i
50
+ offset + per < total_count
50
51
  end
51
52
 
52
53
  def total_pages(total_count)
53
- return MIN_PAGE if total_count.to_i <= 0
54
+ total_count = total_count.to_i
55
+ return MIN_PAGE unless total_count.positive?
54
56
 
55
- [(total_count.to_f / per).ceil, MIN_PAGE].max
57
+ ((total_count + per - 1) / per)
56
58
  end
57
59
  end
58
60
  end
@@ -17,8 +17,14 @@ module LlmCostTracker
17
17
  {}
18
18
  end
19
19
 
20
- def with_indifferent_access(value)
21
- to_hash(value).with_indifferent_access
20
+ def tag_query(value)
21
+ to_hash(value).each_with_object({}) do |(key, tag_value), tags|
22
+ key = key.to_s
23
+ tag_value = tag_value.to_s
24
+ next if key.blank? || tag_value.blank?
25
+
26
+ tags[key] = tag_value
27
+ end
22
28
  end
23
29
  end
24
30
  end
@@ -3,7 +3,7 @@
3
3
  module LlmCostTracker
4
4
  module Dashboard
5
5
  class ProviderBreakdown
6
- def self.call(scope: LlmCostTracker::Ledger::Call.all)
6
+ def self.call(scope: LlmCostTracker::Call.all)
7
7
  new(scope: scope).rows
8
8
  end
9
9
 
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  WINDOW_DAYS = 7
7
7
 
8
8
  class << self
9
- def call(from:, to:, scope: LlmCostTracker::Ledger::Call.all)
9
+ def call(from:, to:, scope: LlmCostTracker::Call.all)
10
10
  new(scope: scope, from: from, to: to).alert
11
11
  end
12
12
  end
@@ -28,13 +28,14 @@ module LlmCostTracker
28
28
  attr_reader :scope, :from, :to
29
29
 
30
30
  def alerts
31
+ window_days = WINDOW_DAYS.to_f
31
32
  daily_spend_by_model.each_with_object([]) do |((provider, model), daily_costs), rows|
32
33
  latest_spend = daily_costs.fetch(to, 0.0)
33
34
  next unless latest_spend.positive?
34
35
 
35
36
  baseline_days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
36
- mean = baseline_days.sum / WINDOW_DAYS.to_f
37
- variance = baseline_days.sum { |value| (value - mean)**2 } / WINDOW_DAYS.to_f
37
+ mean = baseline_days.sum / window_days
38
+ variance = baseline_days.sum { |value| (value - mean)**2 } / window_days
38
39
  threshold = mean + (2 * Math.sqrt(variance))
39
40
  next unless latest_spend > threshold
40
41