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
@@ -19,9 +19,10 @@ module LlmCostTracker
19
19
  format.html do
20
20
  @page = Dashboard::Pagination.call(params)
21
21
  @calls_count = scope.count
22
- @calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
22
+ @calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
23
23
  end
24
24
  format.csv do
25
+ response.headers["Cache-Control"] = "no-store"
25
26
  send_data render_csv(ordered_scope.limit(CSV_EXPORT_LIMIT)),
26
27
  type: "text/csv",
27
28
  disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
@@ -30,7 +31,7 @@ module LlmCostTracker
30
31
  end
31
32
 
32
33
  def show
33
- @call = Ledger::Call.find(params[:id])
34
+ @call = LlmCostTracker::Call.find(params[:id])
34
35
  end
35
36
 
36
37
  private
@@ -55,38 +56,40 @@ module LlmCostTracker
55
56
  CSV.generate do |csv|
56
57
  csv << fields.map(&:to_s)
57
58
 
58
- relation.pluck(*fields).each do |values|
59
- csv << fields.zip(values).map { |field, value| csv_value(field, value) }
59
+ relation.includes(:tag_records).each do |call|
60
+ csv << fields.map { |field| csv_value(field, call) }
60
61
  end
61
62
  end
62
63
  end
63
64
 
64
65
  def csv_fields
65
66
  %i[tracked_at provider model] +
66
- TokenUsage::STORED_KEYS +
67
- Pricing::COST_KEYS +
68
- %i[latency_ms provider_response_id tags]
67
+ TokenUsage.members +
68
+ %i[
69
+ total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
70
+ provider_api_key_id provider_workspace_id batch tags
71
+ ]
69
72
  end
70
73
 
71
- def csv_value(field, value)
74
+ def csv_value(field, call)
72
75
  case field
73
76
  when :tracked_at
74
- value&.utc&.iso8601
75
- when :provider, :model, :provider_response_id
76
- csv_safe(value)
77
+ call.tracked_at&.utc&.iso8601
78
+ when :provider_api_key_id, :provider_workspace_id, :provider_project_id
79
+ csv_safe(LlmCostTracker::Masking.mask_value(field, call[field]))
80
+ when :provider, :model, :provider_response_id, :cost_status
81
+ csv_safe(call[field])
82
+ when :pricing_snapshot
83
+ csv_safe(csv_json(call.pricing_snapshot))
77
84
  when :tags
78
- csv_safe(csv_tags(value))
85
+ csv_safe(call.parsed_tags.to_json)
79
86
  else
80
- value
87
+ call[field]
81
88
  end
82
89
  end
83
90
 
84
- def csv_tags(value)
85
- return value.transform_keys(&:to_s).to_json if value.is_a?(Hash)
86
-
87
- JSON.parse(value || "{}").to_json
88
- rescue JSON::ParserError
89
- "{}"
91
+ def csv_json(value)
92
+ Hash(value).deep_stringify_keys.to_json
90
93
  end
91
94
 
92
95
  def csv_safe(value)
@@ -5,9 +5,21 @@ module LlmCostTracker
5
5
  def index
6
6
  scope = Dashboard::Filter.call(params: params)
7
7
  @stats = Dashboard::DataQuality.call(scope: scope)
8
- @usage_rows = Dashboard::DataQuality.usage_rows(@stats)
8
+ @summary = Dashboard::DataQuality.summary(@stats)
9
+ @usage_rows = Dashboard::DataQuality.usage_rows(
10
+ @stats,
11
+ component_costs: Dashboard::DataQuality.component_costs(scope)
12
+ )
9
13
  @hidden_output_summary = Dashboard::DataQuality.hidden_output_summary(@stats)
10
- @unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(scope)
14
+ @unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(
15
+ scope,
16
+ total_calls: @summary.total
17
+ )
18
+ @service_charge_rows = Dashboard::DataQuality.service_charge_rows(scope).to_a
19
+ @streaming_health_rows = Dashboard::DataQuality.streaming_health_rows(
20
+ scope,
21
+ total_streaming: @summary.streaming_count
22
+ )
11
23
  end
12
24
  end
13
25
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class ReconciliationController < ApplicationController
5
+ def index
6
+ @reconciliation_enabled = LlmCostTracker::Reconciliation.enabled?
7
+ @reconciliation_installed = LlmCostTracker::ProviderInvoice.table_exists?
8
+ if @reconciliation_enabled && @reconciliation_installed
9
+ @scopes = invoice_scopes
10
+ @sources = @scopes.map { |scope| scope[:source] }.uniq
11
+ @diffs = @scopes.filter_map { |scope| diff_for(scope) }
12
+ @last_imported_at = LlmCostTracker::ProviderInvoice.maximum(:imported_at)
13
+ else
14
+ @scopes = []
15
+ @sources = []
16
+ @diffs = []
17
+ @last_imported_at = nil
18
+ end
19
+ @threshold = LlmCostTracker::Reconciliation::DEFAULT_THRESHOLD_PERCENT
20
+ @configured_importers = @reconciliation_enabled ? configured_importers : {}
21
+ end
22
+
23
+ def trigger_import
24
+ unless LlmCostTracker::Reconciliation.enabled?
25
+ return redirect_to reconciliation_path, alert: "Reconciliation is disabled"
26
+ end
27
+
28
+ source = params[:source].to_s
29
+ importer = configured_importers[source.to_sym]
30
+ return redirect_to reconciliation_path, alert: "No importer configured for #{source}" if importer.nil?
31
+
32
+ result = importer.call
33
+ if result.respond_to?(:errors) && result.errors.any?
34
+ LlmCostTracker::Logging.warn(
35
+ "Reconciliation import for #{source} returned #{result.errors.size} row error(s)"
36
+ )
37
+ return redirect_to(
38
+ reconciliation_path,
39
+ alert: "Imported #{result.respond_to?(:total_imported) ? result.total_imported : 0} " \
40
+ "#{source} rows with #{result.errors.size} row error(s); see Rails logs for details."
41
+ )
42
+ end
43
+ message = if result.respond_to?(:total_imported)
44
+ "Imported #{result.total_imported} #{source} rows"
45
+ else
46
+ "Triggered #{source} importer"
47
+ end
48
+ redirect_to reconciliation_path, notice: message
49
+ rescue StandardError => e
50
+ LlmCostTracker::Logging.warn("Reconciliation import failed for #{source}: #{e.class}: #{e.message}")
51
+ redirect_to reconciliation_path,
52
+ alert: "Import failed (#{e.class.name}); see Rails logs for details."
53
+ end
54
+
55
+ private
56
+
57
+ def configured_importers
58
+ LlmCostTracker.configuration.reconciliation_importers
59
+ end
60
+
61
+ def invoice_scopes
62
+ connection = LlmCostTracker::ProviderInvoice.connection
63
+ provider_expr =
64
+ if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
65
+ Arel.sql("metadata->>'provider'")
66
+ else
67
+ Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
68
+ end
69
+ LlmCostTracker::ProviderInvoice
70
+ .group(:source, provider_expr, :currency)
71
+ .order(:source, :currency)
72
+ .pluck(:source, provider_expr, :currency)
73
+ .map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
74
+ end
75
+
76
+ def diff_for(scope)
77
+ window = scope_invoices(scope)
78
+ .order(period_end: :desc, period_start: :desc)
79
+ .limit(1)
80
+ .pick(:period_start, :period_end)
81
+ return nil unless window
82
+
83
+ LlmCostTracker::Reconciliation.diff(
84
+ source: scope[:source], provider: scope[:provider], currency: scope[:currency],
85
+ period_start: window[0], period_end: window[1]
86
+ )
87
+ rescue ArgumentError => e
88
+ LlmCostTracker::Logging.warn("Reconciliation diff skipped for #{scope.inspect}: #{e.message}")
89
+ nil
90
+ end
91
+
92
+ def scope_invoices(scope)
93
+ relation = LlmCostTracker::ProviderInvoice
94
+ .where(source: scope[:source], currency: scope[:currency])
95
+ connection = LlmCostTracker::ProviderInvoice.connection
96
+ provider = scope[:provider]
97
+ return relation if provider.nil? || provider.empty?
98
+
99
+ if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
100
+ relation.where("metadata->>'provider' = ?", provider)
101
+ else
102
+ relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -7,7 +7,21 @@ module LlmCostTracker
7
7
  end
8
8
 
9
9
  def show
10
- @breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: params[:key])
10
+ scope = Dashboard::Filter.call(params: params)
11
+ @value = params[:tag_value].to_s
12
+
13
+ if @value.empty?
14
+ @breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key])
15
+ else
16
+ @key = LlmCostTracker::Tags::Key.validate!(
17
+ params[:key],
18
+ error_class: LlmCostTracker::InvalidFilterError
19
+ )
20
+ value_scope = scope.by_tag(@key, @value)
21
+ @value_total_cost = value_scope.sum(:total_cost).to_f
22
+ @value_calls = value_scope.count
23
+ @value_points = Dashboard::TimeSeries.call(scope: value_scope)
24
+ end
11
25
  end
12
26
  end
13
27
  end
@@ -13,9 +13,11 @@ module LlmCostTracker
13
13
  include ChartHelper
14
14
  include PaginationHelper
15
15
  include TokenUsageHelper
16
+ include InlineStyleHelper
16
17
 
17
18
  def coverage_percent(numerator, denominator)
18
- return 0.0 unless denominator.to_i.positive?
19
+ denominator = denominator.to_f
20
+ return 0.0 unless denominator.positive?
19
21
 
20
22
  (numerator.to_f / denominator) * 100.0
21
23
  end
@@ -39,16 +41,19 @@ module LlmCostTracker
39
41
  number_with_delimiter(value.to_i)
40
42
  end
41
43
 
42
- def format_tokens(value)
43
- number(value)
44
- end
45
-
46
44
  def format_date(value)
47
45
  value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
48
46
  end
49
47
 
50
48
  def pricing_status(call)
51
- call.total_cost.nil? ? "Unknown pricing" : "Estimated"
49
+ return "Unknown pricing" if call.total_cost.nil?
50
+ return "Estimated" unless call.has_attribute?(:cost_status)
51
+
52
+ {
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")
52
57
  end
53
58
 
54
59
  def percent(value)
@@ -100,13 +105,13 @@ module LlmCostTracker
100
105
  value.to_s
101
106
  end
102
107
 
103
- def tags_summary(tags, limit: 3)
104
- tags = normalized_tags(tags)
105
- return "(untagged)" if tags.empty?
108
+ def masked_metadata_hash(value)
109
+ return value if value.is_a?(Hash)
110
+ return {} if value.nil?
106
111
 
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(", ")
112
+ JSON.parse(value.to_s)
113
+ rescue JSON::ParserError, TypeError
114
+ {}
110
115
  end
111
116
 
112
117
  def tag_chip_entries(tags, limit: 3)
@@ -124,14 +129,6 @@ module LlmCostTracker
124
129
  truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
125
130
  end
126
131
 
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
132
  def current_query(overrides = {})
136
133
  request.query_parameters.symbolize_keys.merge(overrides)
137
134
  end
@@ -164,7 +161,7 @@ module LlmCostTracker
164
161
  def truncate_text(string, limit)
165
162
  return string if string.bytesize <= limit
166
163
 
167
- "#{string.byteslice(0, limit).to_s.encode('UTF-8', invalid: :replace, undef: :replace)}..."
164
+ "#{string.byteslice(0, limit).encode('UTF-8', invalid: :replace, undef: :replace)}..."
168
165
  end
169
166
  end
170
167
  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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module InlineStyleHelper
5
+ UNSAFE_CSS_CHARS = /[<>{}"]/
6
+
7
+ def inline_style(declarations)
8
+ registry = inline_style_registry
9
+ token = "lct-i-#{registry.length}"
10
+ registry << [token, declarations.to_s.gsub(UNSAFE_CSS_CHARS, "")]
11
+ token
12
+ end
13
+
14
+ def inline_style_block
15
+ registry = inline_style_registry
16
+ return "".html_safe if registry.empty?
17
+
18
+ rules = registry.map { |token, decl| %([data-lct-style="#{token}"]{#{decl}}) }.join("\n")
19
+ content_tag(:style, rules.html_safe, nonce: dashboard_csp_nonce)
20
+ end
21
+
22
+ private
23
+
24
+ def inline_style_registry
25
+ @inline_style_registry ||= []
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module ReconciliationHelper
5
+ def attribution_summary(attribution)
6
+ LlmCostTracker::Masking.format_attribution(attribution)
7
+ end
8
+
9
+ def mask_secret(value)
10
+ LlmCostTracker::Masking.mask_value(:provider_api_key_id, value)
11
+ end
12
+ end
13
+ end
@@ -6,22 +6,30 @@ 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",
11
+ image_input_tokens: "Image input",
10
12
  output_tokens: "Output",
13
+ audio_output_tokens: "Audio output",
14
+ image_output_tokens: "Image output",
11
15
  hidden_output_tokens: "Hidden output"
12
16
  }.freeze
13
17
  QUALITY_LABELS = COMPONENT_LABELS.merge(
14
18
  input_tokens: "Regular input",
15
19
  cache_read_input_tokens: "Cache read input",
16
20
  cache_write_input_tokens: "Cache write input",
17
- cache_write_1h_input_tokens: "1h cache write input"
21
+ cache_write_extended_input_tokens: "Extended cache write input"
18
22
  ).freeze
19
23
  STACK_CLASSES = {
20
24
  input_tokens: "lct-stack-fill-input",
21
25
  cache_read_input_tokens: "lct-stack-fill-cache-read",
22
26
  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"
27
+ cache_write_extended_input_tokens: "lct-stack-fill-cache-write-extended",
28
+ audio_input_tokens: "lct-stack-fill-audio-input",
29
+ image_input_tokens: "lct-stack-fill-image-input",
30
+ output_tokens: "lct-stack-fill-output",
31
+ audio_output_tokens: "lct-stack-fill-audio-output",
32
+ image_output_tokens: "lct-stack-fill-image-output"
25
33
  }.freeze
26
34
 
27
35
  def token_usage_stack_components
@@ -30,18 +38,26 @@ module LlmCostTracker
30
38
  end
31
39
  end
32
40
 
33
- def token_usage_quality_label(token_key)
34
- QUALITY_LABELS.fetch(token_key.to_sym)
41
+ def call_line_item_costs_by_component(call)
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
48
+ accumulator[component.key] = line_item.cost if component && line_item.cost
49
+ end
35
50
  end
36
51
 
37
52
  private
38
53
 
39
54
  def token_usage_display_components(labels:)
40
- LlmCostTracker::Pricing::COMPONENTS.map do |component|
55
+ LlmCostTracker::Billing::Components::TOKEN_PRICED.map do |component|
41
56
  token_key = component.token_key
42
57
  {
43
58
  token_key: token_key,
44
59
  cost_key: component.cost_key,
60
+ price_key: component.key,
45
61
  label: labels.fetch(token_key),
46
62
  css_class: STACK_CLASSES[token_key]
47
63
  }
@@ -49,6 +65,7 @@ module LlmCostTracker
49
65
  {
50
66
  token_key: :hidden_output_tokens,
51
67
  cost_key: nil,
68
+ price_key: nil,
52
69
  label: labels.fetch(:hidden_output_tokens),
53
70
  css_class: nil
54
71
  }
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
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
+ module LlmCostTracker
10
+ class Call < ActiveRecord::Base
11
+ before_validation :assign_event_id
12
+
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
+ scope :with_cost, -> { where.not(total_cost: nil) }
27
+ 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
+ }
33
+ scope :with_latency, -> { where.not(latency_ms: nil) }
34
+ scope :streaming, -> { where(stream: true) }
35
+ scope :non_streaming, -> { where(stream: [false, nil]) }
36
+ scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
37
+ scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
38
+ 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
+ }
42
+
43
+ has_many :line_items,
44
+ class_name: "LlmCostTracker::CallLineItem",
45
+ foreign_key: :llm_cost_tracker_call_id,
46
+ inverse_of: :call,
47
+ dependent: :delete_all
48
+
49
+ has_many :tag_records,
50
+ class_name: "LlmCostTracker::CallTag",
51
+ foreign_key: :llm_cost_tracker_call_id,
52
+ inverse_of: :call,
53
+ dependent: :delete_all
54
+
55
+ scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
56
+ scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
57
+ scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
58
+ scope :between, ->(from, to) { where(tracked_at: from..to) }
59
+
60
+ class << self
61
+ def by_tag(key, value) = by_tags(key => value)
62
+
63
+ def by_tags(tags) = Ledger::Tags::Query.apply(tags)
64
+
65
+ def total_cost = sum(:total_cost).to_f
66
+
67
+ def total_tokens = sum(:total_tokens).to_i
68
+
69
+ def cost_by_model(limit: nil) = cost_by_column(:model, limit: limit)
70
+
71
+ def cost_by_provider(limit: nil) = cost_by_column(:provider, limit: limit)
72
+
73
+ def group_by_tag(key)
74
+ Ledger::Tags::Sql.join_relation(self, key).group(Ledger::Tags::Sql.value_arel)
75
+ end
76
+
77
+ 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
+ )
88
+ relation = relation.limit(limit) if limit
89
+ relation
90
+ end
91
+
92
+ def average_latency_ms = average(:latency_ms)&.to_f
93
+
94
+ def latency_by_model = group(:model).average(:latency_ms).transform_values(&:to_f)
95
+
96
+ def latency_by_provider = group(:provider).average(:latency_ms).transform_values(&:to_f)
97
+
98
+ def group_by_period(period, column: :tracked_at)
99
+ group(Arel.sql(period_group_expression(period, column: column)))
100
+ end
101
+
102
+ def daily_costs(days: 30)
103
+ where(tracked_at: days.days.ago..)
104
+ .group_by_period(:day)
105
+ .sum(:total_cost)
106
+ end
107
+
108
+ private
109
+
110
+ 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")
113
+ .group(column)
114
+ .order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
115
+ relation = relation.limit(limit) if limit
116
+ relation
117
+ end
118
+
119
+ 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}"
144
+ end
145
+
146
+ def period_column_expression(column)
147
+ column = column.to_s
148
+ return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
149
+
150
+ raise ArgumentError, "invalid period column: #{column.inspect}"
151
+ end
152
+ end
153
+
154
+ def parsed_tags
155
+ tag_records.to_h do |record|
156
+ [record.key, record.value]
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ def assign_event_id
163
+ self.event_id ||= SecureRandom.uuid
164
+ end
165
+ end
166
+ end