llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -10,6 +10,7 @@ module LlmCostTracker
10
10
 
11
11
  before_action :set_dashboard_security_headers
12
12
  before_action :ensure_current_schema
13
+ before_action :assign_dashboard_date_range
13
14
 
14
15
  helper_method :dashboard_csp_nonce
15
16
 
@@ -30,6 +31,12 @@ module LlmCostTracker
30
31
  render template: "llm_cost_tracker/shared/setup_required"
31
32
  end
32
33
 
34
+ def assign_dashboard_date_range
35
+ range = LlmCostTracker::Dashboard::DateRange.call(params: params)
36
+ @from_date = range.from
37
+ @to_date = range.to
38
+ end
39
+
33
40
  def render_database_error(error)
34
41
  @error = error
35
42
  render "llm_cost_tracker/errors/database", status: :internal_server_error
@@ -48,8 +55,13 @@ module LlmCostTracker
48
55
  nonce = dashboard_csp_nonce
49
56
  response.headers["X-Frame-Options"] = "DENY"
50
57
  response.headers["Referrer-Policy"] = "same-origin"
51
- response.headers["Content-Security-Policy"] =
52
- "default-src 'self'; style-src 'self' 'nonce-#{nonce}'; img-src 'self' data:; frame-ancestors 'none'"
58
+ response.headers["Content-Security-Policy"] = [
59
+ "default-src 'self'",
60
+ "script-src 'self' 'nonce-#{nonce}'",
61
+ "style-src 'self' 'nonce-#{nonce}'",
62
+ "img-src 'self' data:",
63
+ "frame-ancestors 'none'"
64
+ ].join("; ")
53
65
  end
54
66
 
55
67
  def dashboard_csp_nonce
@@ -8,19 +8,25 @@ module LlmCostTracker
8
8
  CSV_EXPORT_LIMIT = 10_000
9
9
  CSV_EXPORT_BATCH_SIZE = 500
10
10
  CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
11
- DEFAULT_ORDER = "tracked_at DESC, id DESC"
11
+ DEFAULT_TIEBREAKER = { tracked_at: :desc, id: :desc }.freeze
12
+ SORT_OPTIONS = %w[tracked_at provider model input output cost latency].freeze
13
+ NULLS_LAST_GUARD = {
14
+ total_cost: Arel.sql("CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC"),
15
+ latency_ms: Arel.sql("CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC")
16
+ }.freeze
12
17
 
13
18
  def index
14
19
  @sort = params[:sort].to_s
20
+ @dir = params[:dir].to_s
15
21
  scope = Dashboard::Filter.call(params: params)
16
- scope = scope.unknown_pricing if @sort == "unknown_pricing"
17
- ordered_scope = scope.order(Arel.sql(calls_order(@sort)))
22
+ scope = scope.unknown_pricing if params[:cost_status].to_s == "incomplete"
23
+ ordered_scope = scope.order(*calls_order(@sort, @dir))
18
24
 
19
25
  respond_to do |format|
20
26
  format.html do
21
27
  @page = Dashboard::Pagination.call(params)
22
- @calls_count = scope.count
23
- @calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
28
+ @calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
29
+ @calls = ordered_scope.includes(:tag_records).limit(@page.per).offset(@page.offset).to_a
24
30
  end
25
31
  format.csv do
26
32
  response.headers["Cache-Control"] = "no-store"
@@ -37,18 +43,19 @@ module LlmCostTracker
37
43
 
38
44
  private
39
45
 
40
- def calls_order(sort)
41
- case sort
42
- when "expensive"
43
- "CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{DEFAULT_ORDER}"
44
- when "input"
45
- "input_tokens DESC, #{DEFAULT_ORDER}"
46
- when "output"
47
- "output_tokens DESC, #{DEFAULT_ORDER}"
48
- when "slow"
49
- "CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
50
- else
51
- DEFAULT_ORDER
46
+ def calls_order(sort, dir)
47
+ column = SORT_OPTIONS.include?(sort) ? sort.to_sym : :tracked_at
48
+ natural = %i[provider model].include?(column) ? :asc : :desc
49
+ direction = Dashboard::Sort::DIRECTIONS.include?(dir.downcase) ? dir.downcase.to_sym : natural
50
+
51
+ case column
52
+ when :tracked_at then [{ tracked_at: direction, id: direction }]
53
+ when :provider then [{ provider: direction, model: :asc, **DEFAULT_TIEBREAKER }]
54
+ when :model then [{ model: direction, **DEFAULT_TIEBREAKER }]
55
+ when :input then [{ input_tokens: direction, **DEFAULT_TIEBREAKER }]
56
+ when :output then [{ output_tokens: direction, **DEFAULT_TIEBREAKER }]
57
+ when :cost then [NULLS_LAST_GUARD[:total_cost], { total_cost: direction, **DEFAULT_TIEBREAKER }]
58
+ when :latency then [NULLS_LAST_GUARD[:latency_ms], { latency_ms: direction, **DEFAULT_TIEBREAKER }]
52
59
  end
53
60
  end
54
61
 
@@ -76,7 +83,7 @@ module LlmCostTracker
76
83
 
77
84
  def csv_fields
78
85
  %i[tracked_at provider model] +
79
- TokenUsage.members +
86
+ Usage::TokenUsage.members +
80
87
  %i[
81
88
  total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
82
89
  provider_api_key_id provider_workspace_id batch tags
@@ -86,15 +93,15 @@ module LlmCostTracker
86
93
  def csv_value(field, call)
87
94
  case field
88
95
  when :tracked_at
89
- call.tracked_at&.utc&.iso8601
96
+ call.tracked_at.utc.iso8601
90
97
  when :provider_api_key_id, :provider_workspace_id, :provider_project_id
91
- csv_safe(LlmCostTracker::Masking.mask_value(field, call[field]))
98
+ csv_safe(LlmCostTracker::Dashboard::Masking.mask_value(field, call[field]))
92
99
  when :provider, :model, :provider_response_id, :cost_status
93
100
  csv_safe(call[field])
94
101
  when :pricing_snapshot
95
102
  csv_safe(csv_json(call.pricing_snapshot))
96
103
  when :tags
97
- csv_safe(call.parsed_tags.to_json)
104
+ csv_safe(call.tag_pairs.to_json)
98
105
  else
99
106
  call[field]
100
107
  end
@@ -3,9 +3,6 @@
3
3
  module LlmCostTracker
4
4
  class DashboardController < ApplicationController
5
5
  def index
6
- range = Dashboard::DateRange.call(params: params)
7
- @from_date = range.from
8
- @to_date = range.to
9
6
  prev_from, prev_to = previous_range
10
7
  filter_params = LlmCostTracker::Dashboard::Params.to_hash(params)
11
8
  scope = Dashboard::Filter.call(
@@ -16,7 +13,7 @@ module LlmCostTracker
16
13
  )
17
14
 
18
15
  @stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
19
- @monthly_budget_status = Dashboard::OverviewStats.monthly_budget_status
16
+ @monthly_budget_status = Dashboard::MonthlyBudget.status
20
17
  @time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
21
18
  @comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
22
19
  @spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
@@ -4,10 +4,12 @@ module LlmCostTracker
4
4
  class ModelsController < ApplicationController
5
5
  def index
6
6
  @sort = params[:sort].to_s
7
+ @dir = params[:dir].to_s
7
8
  @rows = Dashboard::TopModels.call(
8
9
  scope: Dashboard::Filter.call(params: params),
9
10
  limit: nil,
10
- sort: @sort
11
+ sort: @sort,
12
+ direction: @dir
11
13
  )
12
14
  end
13
15
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class PricingController < ApplicationController
5
+ def index
6
+ @overview = Dashboard::PricingOverview.call
7
+ requested = params[:source]&.to_sym
8
+ @active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
9
+ @source_data = @overview.fetch(:sources).fetch(@active_source)
10
+ @provider_filter = params[:provider].to_s.presence
11
+ @rows = @source_data.fetch(:rows)
12
+ @rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
13
+ @providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
14
+ end
15
+ end
16
+ end
@@ -11,7 +11,9 @@ module LlmCostTracker
11
11
  @value = params[:tag_value].to_s
12
12
 
13
13
  if @value.empty?
14
- @breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key])
14
+ @sort = params[:sort].to_s
15
+ @dir = params[:dir].to_s
16
+ @breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key], sort: @sort, direction: @dir)
15
17
  else
16
18
  @key = LlmCostTracker::Tags::Key.validate!(
17
19
  params[:key],
@@ -7,7 +7,6 @@ module LlmCostTracker
7
7
  TAG_VALUE_SUMMARY_BYTES = 80
8
8
  TAG_TOOLTIP_BYTES = 512
9
9
 
10
- include DashboardFilterHelper
11
10
  include DashboardFilterOptionsHelper
12
11
  include DashboardQueryHelper
13
12
  include ChartHelper
@@ -15,6 +14,17 @@ module LlmCostTracker
15
14
  include TokenUsageHelper
16
15
  include InlineStyleHelper
17
16
 
17
+ def dashboard_section
18
+ path = request.path.to_s
19
+ return :models if path.start_with?(models_path)
20
+ return :calls if path.start_with?(calls_path)
21
+ return :tags if path.start_with?(tags_path)
22
+ return :data_quality if path.start_with?(data_quality_path)
23
+ return :pricing if path.start_with?(pricing_path)
24
+
25
+ :overview
26
+ end
27
+
18
28
  def coverage_percent(numerator, denominator)
19
29
  denominator = denominator.to_f
20
30
  return 0.0 unless denominator.positive?
@@ -33,27 +43,20 @@ module LlmCostTracker
33
43
  value.nil? ? "n/a" : money(value)
34
44
  end
35
45
 
36
- def optional_number(value)
37
- value.nil? ? "n/a" : number(value)
38
- end
39
-
40
- def number(value)
41
- number_with_delimiter(value)
42
- end
43
-
44
46
  def format_date(value)
45
- value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
47
+ return "" if value.nil?
48
+
49
+ value.strftime("%Y-%m-%d %H:%M")
46
50
  end
47
51
 
48
52
  def pricing_status(call)
49
- return "Unknown pricing" if call.total_cost.nil?
50
- return "Estimated" unless call.has_attribute?(:cost_status)
53
+ return "Unknown" if call.total_cost.nil?
51
54
 
52
55
  {
53
- LlmCostTracker::Billing::CostStatus::COMPLETE => "Estimated",
54
- LlmCostTracker::Billing::CostStatus::FREE => "Free",
55
- LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial pricing"
56
- }.fetch(call.cost_status, "Unknown pricing")
56
+ LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
57
+ LlmCostTracker::Charges::CostStatus::FREE => "Free",
58
+ LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
59
+ }.fetch(call.cost_status, "Unknown")
57
60
  end
58
61
 
59
62
  def percent(value)
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  return nil if points.blank?
7
7
 
8
8
  cfg = chart_config(points, comparison_points, height, y_ticks)
9
- parts = [chart_svg_open(cfg)]
9
+ parts = [chart_svg_open(cfg), "<title>Daily spend trend</title>", chart_area_gradient_def]
10
10
  parts.concat(chart_grid_and_axis(cfg))
11
11
  parts << chart_paths(cfg)
12
12
  parts.concat(chart_dots(cfg))
@@ -22,7 +22,7 @@ module LlmCostTracker
22
22
  end
23
23
 
24
24
  def chart_config(points, comparison_points, height, y_ticks)
25
- width = 720
25
+ width = 1180
26
26
  pad = { top: 16, right: 16, bottom: 28, left: 56 }
27
27
  plot_w = width - pad[:left] - pad[:right]
28
28
  plot_h = height - pad[:top] - pad[:bottom]
@@ -31,9 +31,11 @@ module LlmCostTracker
31
31
  coords = chart_coords(points, pad, plot_w, plot_h, max_cost)
32
32
  comparison_coords = chart_coords(comparison_points, pad, plot_w, plot_h, max_cost) if comparison_points.present?
33
33
 
34
+ peak_index = points.each_with_index.max_by { |point, _| point[:cost].to_f }&.last
34
35
  { width: width, height: height, pad: pad, plot_w: plot_w, plot_h: plot_h,
35
36
  max_cost: max_cost, n: points.size, y_ticks: y_ticks, points: points, coords: coords,
36
- comparison_points: comparison_points, comparison_coords: comparison_coords }
37
+ comparison_points: comparison_points, comparison_coords: comparison_coords,
38
+ peak_index: peak_index }
37
39
  end
38
40
 
39
41
  def chart_coords(points, pad, plot_w, plot_h, max_cost)
@@ -51,7 +53,7 @@ module LlmCostTracker
51
53
  attrs = [
52
54
  %(class="lct-chart"),
53
55
  %(viewBox="0 0 #{cfg[:width]} #{cfg[:height]}"),
54
- %(preserveAspectRatio="none"),
56
+ %(preserveAspectRatio="xMidYMid meet"),
55
57
  %(role="img"),
56
58
  %(aria-label="Daily spend trend")
57
59
  ].join(" ")
@@ -114,10 +116,20 @@ module LlmCostTracker
114
116
  def chart_dot(cfg, pt_x, pt_y, idx)
115
117
  point = cfg[:points][idx]
116
118
  title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
117
- circle = %(<circle class="lct-chart-dot" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="3"/>)
119
+ peak = idx == cfg[:peak_index]
120
+ klass = peak ? "lct-chart-peak" : "lct-chart-dot"
121
+ radius = peak ? 4 : 3
122
+ circle = %(<circle class="#{klass}" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="#{radius}"/>)
118
123
  "<g>#{circle}<title>#{title}</title></g>"
119
124
  end
120
125
 
126
+ def chart_area_gradient_def
127
+ "<defs><linearGradient id=\"lct-chart-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" \
128
+ "<stop offset=\"0%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.28\"/>" \
129
+ "<stop offset=\"100%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.02\"/>" \
130
+ "</linearGradient></defs>"
131
+ end
132
+
121
133
  def chart_x_labels(cfg)
122
134
  indexes = cfg[:n] <= 2 ? (0...cfg[:n]).to_a : [0, cfg[:n] / 2, cfg[:n] - 1].uniq
123
135
  label_y = chart_fmt(cfg[:height] - 8)
@@ -127,7 +139,11 @@ module LlmCostTracker
127
139
  def chart_x_label(cfg, idx, label_y)
128
140
  pt_x, = cfg[:coords][idx]
129
141
  label = ERB::Util.html_escape(cfg[:points][idx][:label])
130
- %(<text class="lct-chart-axis" x="#{chart_fmt(pt_x)}" y="#{label_y}" text-anchor="middle">#{label}</text>)
142
+ anchor = if idx.zero? then "start"
143
+ elsif idx == cfg[:n] - 1 then "end"
144
+ else "middle"
145
+ end
146
+ %(<text class="lct-chart-axis" x="#{chart_fmt(pt_x)}" y="#{label_y}" text-anchor="#{anchor}">#{label}</text>)
131
147
  end
132
148
  end
133
149
  end
@@ -4,17 +4,7 @@ module LlmCostTracker
4
4
  module DashboardFilterOptionsHelper
5
5
  MAX_FILTER_OPTIONS = 100
6
6
 
7
- def provider_filter_options(filter_params: params)
8
- filter_options_for(:provider, filter_params: filter_params)
9
- end
10
-
11
- def model_filter_options(filter_params: params)
12
- filter_options_for(:model, filter_params: filter_params)
13
- end
14
-
15
- private
16
-
17
- def filter_options_for(column, filter_params:)
7
+ def filter_options_for(column, filter_params: params)
18
8
  source = LlmCostTracker::Dashboard::Params.to_hash(filter_params).symbolize_keys
19
9
  scope_params = source.merge(
20
10
  column => nil, format: nil, page: nil, per: nil, sort: nil
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module SortableTableHelper
5
+ def sortable_header(label, column, num: false, default: false)
6
+ state = sortable_state(column, num: num, default: default)
7
+ classes = ["lct-sortable"]
8
+ classes << "lct-num" if num
9
+ classes << "lct-sorted" if state[:active]
10
+
11
+ href = dashboard_filter_path(current_query(sort: column, dir: state[:next_dir], page: nil))
12
+ tag.th(class: classes.join(" "), "aria-sort": state[:aria_sort]) do
13
+ link_to(href) { safe_join([label, " ", tag.span(state[:arrow], class: "lct-sort-ind")]) }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def sortable_state(column, num:, default: false)
20
+ current_sort = params[:sort].presence || (default ? column : nil)
21
+ current_dir = Dashboard::Sort::DIRECTIONS.include?(params[:dir].to_s) ? params[:dir].to_s : nil
22
+ natural_dir = num ? "desc" : "asc"
23
+ active = current_sort == column
24
+ effective_dir = active ? (current_dir || natural_dir) : natural_dir
25
+ flipped = effective_dir == "asc" ? "desc" : "asc"
26
+
27
+ {
28
+ active: active,
29
+ next_dir: active ? flipped : natural_dir,
30
+ arrow: active && effective_dir == "asc" ? "▲" : "▼",
31
+ aria_sort: sortable_aria(active, effective_dir)
32
+ }
33
+ end
34
+
35
+ def sortable_aria(active, effective_dir)
36
+ return "none" unless active
37
+
38
+ effective_dir == "asc" ? "ascending" : "descending"
39
+ end
40
+ end
41
+ end
@@ -40,11 +40,9 @@ module LlmCostTracker
40
40
 
41
41
  def call_line_item_costs_by_component(call)
42
42
  call.line_items.each_with_object({}) do |line_item, accumulator|
43
- component = LlmCostTracker::Billing::Components::TOKEN_PRICED.find do |item|
44
- item.kind.to_s == line_item.kind.to_s &&
45
- item.direction.to_s == line_item.direction.to_s &&
46
- item.cache_state.to_s == line_item.cache_state.to_s
47
- end
43
+ component = LlmCostTracker::Usage::Catalog.token_priced_for(
44
+ kind: line_item.kind, direction: line_item.direction, cache_state: line_item.cache_state
45
+ )
48
46
  accumulator[component.key] = line_item.cost if component && line_item.cost
49
47
  end
50
48
  end
@@ -52,7 +50,7 @@ module LlmCostTracker
52
50
  private
53
51
 
54
52
  def token_usage_display_components(labels:)
55
- LlmCostTracker::Billing::Components::TOKEN_PRICED.map do |component|
53
+ LlmCostTracker::Usage::Catalog.token_priced.map do |component|
56
54
  token_key = component.token_key
57
55
  {
58
56
  token_key: token_key,
@@ -2,43 +2,26 @@
2
2
 
3
3
  require "securerandom"
4
4
 
5
- require "llm_cost_tracker/billing/cost_status"
6
- require "llm_cost_tracker/ledger/schema/adapter"
7
- require "llm_cost_tracker/ledger/tags/sql"
8
-
9
5
  module LlmCostTracker
10
6
  class Call < ActiveRecord::Base
11
7
  before_validation :assign_event_id
12
8
 
13
- PERIOD_FORMATS = {
14
- day: {
15
- postgres: "YYYY-MM-DD",
16
- mysql: "%Y-%m-%d"
17
- },
18
- month: {
19
- postgres: "YYYY-MM",
20
- mysql: "%Y-%m"
21
- }
22
- }.freeze
23
-
24
- private_constant :PERIOD_FORMATS
25
-
26
9
  scope :with_cost, -> { where.not(total_cost: nil) }
27
10
  scope :without_cost, -> { where(total_cost: nil) }
28
- scope :unknown_pricing, lambda {
29
- where(total_cost: nil).or(
30
- where(cost_status: [Billing::CostStatus::UNKNOWN, Billing::CostStatus::PARTIAL])
31
- )
32
- }
11
+ scope :unknown_pricing,
12
+ lambda {
13
+ where(Charges::CostStatus.unknown_pricing_sql)
14
+ }
33
15
  scope :with_latency, -> { where.not(latency_ms: nil) }
34
16
  scope :streaming, -> { where(stream: true) }
35
17
  scope :non_streaming, -> { where(stream: [false, nil]) }
36
18
  scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
37
19
  scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
38
20
  scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
39
- scope :streaming_missing_usage, lambda {
40
- where(stream: true).where(usage_source: ["unknown", nil])
41
- }
21
+ scope :streaming_missing_usage,
22
+ lambda {
23
+ where(stream: true).where(usage_source: [Usage::Source::UNKNOWN, nil])
24
+ }
42
25
 
43
26
  has_many :line_items,
44
27
  class_name: "LlmCostTracker::CallLineItem",
@@ -58,6 +41,12 @@ module LlmCostTracker
58
41
  scope :between, ->(from, to) { where(tracked_at: from..to) }
59
42
 
60
43
  class << self
44
+ def already_recorded?(provider:, provider_response_id:)
45
+ return false if provider_response_id.to_s.empty?
46
+
47
+ where(provider: provider, provider_response_id: provider_response_id).exists?
48
+ end
49
+
61
50
  def by_tag(key, value) = by_tags(key => value)
62
51
 
63
52
  def by_tags(tags) = Ledger::Tags::Query.apply(tags)
@@ -71,20 +60,20 @@ module LlmCostTracker
71
60
  def cost_by_provider(limit: nil) = cost_by_column(:provider, limit: limit)
72
61
 
73
62
  def group_by_tag(key)
74
- Ledger::Tags::Sql.join_relation(self, key).group(Ledger::Tags::Sql.value_arel)
63
+ Ledger::Tags::Breakdown.join_relation(self, key).group(Ledger::Tags::Breakdown.value_arel)
75
64
  end
76
65
 
77
66
  def cost_by_tag(key, limit: nil)
78
- label = Ledger::Tags::Sql.label_sql(connection)
79
- raw_value = Ledger::Tags::Sql.raw_value_sql(connection)
80
- relation = Ledger::Tags::Sql.join_relation(self, key)
81
- .select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
82
- .group(Arel.sql(label))
83
- .order(
84
- Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
85
- Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
86
- Arel.sql("#{label} DESC")
87
- )
67
+ label = Ledger::Tags::Breakdown.label_sql(connection)
68
+ raw_value = Ledger::Tags::Breakdown.raw_value_sql(connection)
69
+ relation = Ledger::Tags::Breakdown.join_relation(self, key)
70
+ .select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
71
+ .group(Arel.sql(label))
72
+ .order(
73
+ Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
74
+ Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
75
+ Arel.sql("#{label} DESC")
76
+ )
88
77
  relation = relation.limit(limit) if limit
89
78
  relation
90
79
  end
@@ -108,8 +97,7 @@ module LlmCostTracker
108
97
  private
109
98
 
110
99
  def cost_by_column(column, limit:)
111
- quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
112
- relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
100
+ relation = select(arel_table[column].as("name"), "COALESCE(SUM(total_cost), 0) AS total_cost")
113
101
  .group(column)
114
102
  .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
115
103
  relation = relation.limit(limit) if limit
@@ -117,30 +105,7 @@ module LlmCostTracker
117
105
  end
118
106
 
119
107
  def period_group_expression(period, column:)
120
- period = validated_period(period)
121
- column = period_column_expression(column)
122
- formats = PERIOD_FORMATS.fetch(period)
123
-
124
- if Ledger::Schema::Adapter.postgresql?(connection)
125
- postgres_period_expression(period, column, formats)
126
- elsif Ledger::Schema::Adapter.mysql?(connection)
127
- "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
128
- else
129
- Ledger::Schema::Adapter.ensure_supported!(connection)
130
- end
131
- end
132
-
133
- def postgres_period_expression(period, column, formats)
134
- "TO_CHAR(" \
135
- "DATE_TRUNC(#{connection.quote(period.name)}, #{column}), " \
136
- "#{connection.quote(formats.fetch(:postgres))}" \
137
- ")"
138
- end
139
-
140
- def validated_period(period)
141
- return period if PERIOD_FORMATS.key?(period)
142
-
143
- raise ArgumentError, "invalid period: #{period.inspect}"
108
+ Ledger::Schema::Adapter.period_bucket_sql(connection, period, period_column_expression(column))
144
109
  end
145
110
 
146
111
  def period_column_expression(column)
@@ -151,7 +116,7 @@ module LlmCostTracker
151
116
  end
152
117
  end
153
118
 
154
- def parsed_tags
119
+ def tag_pairs
155
120
  tag_records.to_h do |record|
156
121
  [record.key, record.value]
157
122
  end
@@ -12,7 +12,7 @@ module LlmCostTracker
12
12
  scope :by_direction, ->(direction) { where(direction: direction.to_s) }
13
13
  scope :by_modality, ->(modality) { where(modality: modality.to_s) }
14
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") }
15
+ scope :priced, -> { where(cost_status: [Charges::CostStatus::COMPLETE, Charges::CostStatus::FREE]) }
16
+ scope :unpriced, -> { where(cost_status: Charges::CostStatus::UNKNOWN) }
17
17
  end
18
18
  end
@@ -2,5 +2,43 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  class CallRollup < ActiveRecord::Base
5
+ class << self
6
+ def increment_all(rows)
7
+ upsert_all(rows, on_duplicate: increment_on_duplicate, record_timestamps: true, unique_by: increment_unique_by)
8
+ end
9
+
10
+ def decrement(buckets)
11
+ now = Time.now.utc
12
+ buckets.each do |(period, period_start, currency, provider), amount|
13
+ where(period: period, period_start: period_start, currency: currency, provider: provider)
14
+ .update_all(["total_cost = GREATEST(0, total_cost - ?), updated_at = ?", amount, now])
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def increment_on_duplicate
21
+ return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(connection)
22
+ return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(connection)
23
+
24
+ Ledger::Schema::Adapter.ensure_supported!(connection)
25
+ end
26
+
27
+ def postgres_increment_sql
28
+ total = connection.quote_column_name("total_cost")
29
+ updated = connection.quote_column_name("updated_at")
30
+ "#{total} = #{quoted_table_name}.#{total} + excluded.#{total}, #{updated} = excluded.#{updated}"
31
+ end
32
+
33
+ def mysql_increment_sql
34
+ "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
35
+ end
36
+
37
+ def increment_unique_by
38
+ return unless connection.supports_insert_conflict_target?
39
+
40
+ %i[period period_start currency provider]
41
+ end
42
+ end
5
43
  end
6
44
  end
@@ -6,7 +6,5 @@ module LlmCostTracker
6
6
  class_name: "LlmCostTracker::Call",
7
7
  foreign_key: :llm_cost_tracker_call_id,
8
8
  inverse_of: :tag_records
9
-
10
- scope :with_key, ->(key) { where(key: key.to_s) }
11
9
  end
12
10
  end
@@ -4,6 +4,8 @@ module LlmCostTracker
4
4
  module Ingestion
5
5
  class InboxEntry < ActiveRecord::Base
6
6
  MAX_ATTEMPTS_BEFORE_QUARANTINE = 5
7
+
8
+ scope :pending, -> { where(attempts: ..(MAX_ATTEMPTS_BEFORE_QUARANTINE - 1)) }
7
9
  end
8
10
  end
9
11
  end