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
@@ -4,9 +4,10 @@ module LlmCostTracker
4
4
  module Dashboard
5
5
  class TagBreakdown
6
6
  DEFAULT_LIMIT = 100
7
+ Row = Data.define(:value, :calls, :total_cost, :average_cost_per_call, :share_percent)
7
8
 
8
9
  class << self
9
- def call(key:, scope: LlmCostTracker::Ledger::Call.all, limit: DEFAULT_LIMIT)
10
+ def call(key:, scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
10
11
  new(scope: scope, key: key, limit: limit)
11
12
  end
12
13
  end
@@ -21,7 +22,19 @@ module LlmCostTracker
21
22
  end
22
23
 
23
24
  def rows
24
- @rows ||= scope.klass.find_by_sql(rows_sql)
25
+ @rows ||= begin
26
+ total = tagged_calls
27
+ scope.klass.find_by_sql(rows_sql).map do |row|
28
+ calls = row.calls.to_i
29
+ Row.new(
30
+ value: row.value,
31
+ calls: calls,
32
+ total_cost: row.total_cost,
33
+ average_cost_per_call: row.average_cost_per_call,
34
+ share_percent: percentage(calls, total)
35
+ )
36
+ end
37
+ end
25
38
  end
26
39
 
27
40
  def total_calls
@@ -46,13 +59,14 @@ module LlmCostTracker
46
59
 
47
60
  def rows_sql
48
61
  <<~SQL.squish
49
- SELECT #{tag_expression} AS value,
62
+ SELECT #{tag_value_column} AS value,
50
63
  COUNT(*) AS calls,
51
64
  COALESCE(SUM(sub.total_cost), 0) AS total_cost,
52
65
  COALESCE(SUM(sub.total_cost), 0) / NULLIF(COUNT(*), 0) AS average_cost_per_call
53
66
  FROM (#{scope.to_sql}) AS sub
67
+ INNER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
54
68
  WHERE #{tag_present_predicate}
55
- GROUP BY #{tag_expression}
69
+ GROUP BY #{tag_value_column}
56
70
  ORDER BY total_cost DESC, calls DESC, value ASC
57
71
  LIMIT #{limit}
58
72
  SQL
@@ -61,18 +75,37 @@ module LlmCostTracker
61
75
  def summary_sql
62
76
  <<~SQL.squish
63
77
  SELECT COUNT(*) AS total_calls,
64
- COALESCE(SUM(CASE WHEN #{tag_present_predicate} THEN 1 ELSE 0 END), 0) AS tagged_calls,
65
- COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_expression} END) AS distinct_values
78
+ COUNT(t.#{quote_column('value')}) AS tagged_calls,
79
+ COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_value_column} END) AS distinct_values
66
80
  FROM (#{scope.to_sql}) AS sub
81
+ LEFT OUTER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
67
82
  SQL
68
83
  end
69
84
 
70
85
  def tag_present_predicate
71
- "#{tag_expression} IS NOT NULL AND #{tag_expression} != ''"
86
+ "#{tag_value_column} IS NOT NULL AND #{tag_value_column} != ''"
87
+ end
88
+
89
+ def tag_value_column
90
+ "t.#{quote_column('value')}"
91
+ end
92
+
93
+ def call_tag_table
94
+ LlmCostTracker::CallTag.quoted_table_name
72
95
  end
73
96
 
74
- def tag_expression
75
- @tag_expression ||= LlmCostTracker::Ledger::Call.tag_value_expression(key, table_name: "sub")
97
+ def quote_column(name)
98
+ scope.connection.quote_column_name(name)
99
+ end
100
+
101
+ def quoted_key
102
+ scope.connection.quote(key)
103
+ end
104
+
105
+ def percentage(numerator, denominator)
106
+ return 0.0 unless denominator.positive?
107
+
108
+ (numerator / denominator.to_f) * 100.0
76
109
  end
77
110
  end
78
111
  end
@@ -6,14 +6,14 @@ module LlmCostTracker
6
6
  DEFAULT_LIMIT = 100
7
7
 
8
8
  class << self
9
- def call(scope: LlmCostTracker::Ledger::Call.all, limit: DEFAULT_LIMIT)
9
+ def call(scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
10
10
  new(scope: scope, limit: limit).rows
11
11
  end
12
12
  end
13
13
 
14
14
  def initialize(scope:, limit:)
15
15
  @scope = scope
16
- @connection = LlmCostTracker::Ledger::Call.connection
16
+ @connection = LlmCostTracker::Call.connection
17
17
  limit = limit.to_i
18
18
  @limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
19
19
  end
@@ -29,50 +29,27 @@ module LlmCostTracker
29
29
 
30
30
  attr_reader :scope, :connection, :limit
31
31
 
32
- def subquery
33
- scope.to_sql
34
- end
35
-
36
32
  def build_sql
37
- return postgresql_sql if Ledger::Schema::Adapter.postgresql?(connection)
38
- return mysql_sql if Ledger::Schema::Adapter.mysql?(connection)
39
-
40
- Ledger::Schema::Adapter.ensure_supported!(connection)
41
- end
33
+ tags_table = LlmCostTracker::CallTag.quoted_table_name
42
34
 
43
- def mysql_sql
44
35
  <<~SQL.squish
45
- SELECT jt.key AS key,
36
+ SELECT t.#{key_column} AS #{key_column},
46
37
  COUNT(*) AS calls_count,
47
- COUNT(DISTINCT JSON_UNQUOTE(JSON_EXTRACT(sub.tags, CONCAT('$.', JSON_QUOTE(jt.key))))) AS distinct_values
48
- FROM (#{subquery}) AS sub
49
- JOIN JSON_TABLE(
50
- COALESCE(JSON_KEYS(sub.tags), JSON_ARRAY()),
51
- '$[*]' COLUMNS(
52
- key VARCHAR(255) PATH '$'
53
- )
54
- ) AS jt
55
- WHERE sub.tags IS NOT NULL
56
- AND sub.tags != ''
57
- GROUP BY jt.key
38
+ COUNT(DISTINCT t.#{value_column}) AS distinct_values
39
+ FROM (#{scope.to_sql}) AS sub
40
+ INNER JOIN #{tags_table} t ON t.llm_cost_tracker_call_id = sub.id
41
+ GROUP BY t.#{key_column}
58
42
  ORDER BY calls_count DESC
59
43
  LIMIT #{limit}
60
44
  SQL
61
45
  end
62
46
 
63
- def postgresql_sql
64
- <<~SQL.squish
65
- SELECT key,
66
- COUNT(*) AS calls_count,
67
- COUNT(DISTINCT (sub.tags::jsonb)->>key) AS distinct_values
68
- FROM (#{subquery}) AS sub,
69
- jsonb_object_keys(sub.tags::jsonb) AS key
70
- WHERE sub.tags IS NOT NULL
71
- AND sub.tags::jsonb <> '{}'::jsonb
72
- GROUP BY key
73
- ORDER BY calls_count DESC
74
- LIMIT #{limit}
75
- SQL
47
+ def key_column
48
+ connection.quote_column_name("key")
49
+ end
50
+
51
+ def value_column
52
+ connection.quote_column_name("value")
76
53
  end
77
54
  end
78
55
  end
@@ -8,7 +8,7 @@ module LlmCostTracker
8
8
  DEFAULT_DAYS = 30
9
9
 
10
10
  class << self
11
- def call(scope: LlmCostTracker::Ledger::Call.all, from: nil, to: Date.current)
11
+ def call(scope: LlmCostTracker::Call.all, from: nil, to: Date.current)
12
12
  new(scope: scope, from: from, to: to).points
13
13
  end
14
14
  end
@@ -8,7 +8,7 @@ module LlmCostTracker
8
8
  DEFAULT_SORT = "cost"
9
9
 
10
10
  class << self
11
- def call(scope: LlmCostTracker::Ledger::Call.all, limit: DEFAULT_LIMIT, sort: DEFAULT_SORT)
11
+ def call(scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT, sort: DEFAULT_SORT)
12
12
  new(scope: scope, limit: limit, sort: sort).rows
13
13
  end
14
14
  end
@@ -1,3 +1,4 @@
1
+ <% body = capture { yield } %>
1
2
  <!DOCTYPE html>
2
3
  <html lang="en">
3
4
  <head>
@@ -5,6 +6,7 @@
5
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
7
  <title>LLM Cost Tracker</title>
7
8
  <%= stylesheet_link_tag stylesheet_path %>
9
+ <%= inline_style_block %>
8
10
  </head>
9
11
  <body class="lct-body">
10
12
  <div class="lct-app">
@@ -19,10 +21,13 @@
19
21
  <%= link_to "Calls", calls_path, class: ("lct-active" if request.path.start_with?(calls_path)) %>
20
22
  <%= link_to "Tags", tags_path, class: ("lct-active" if request.path.start_with?(tags_path)) %>
21
23
  <%= link_to "Data Quality", data_quality_path, class: ("lct-active" if request.path.start_with?(data_quality_path)) %>
24
+ <% if LlmCostTracker.reconciliation_enabled? %>
25
+ <%= link_to "Reconciliation", reconciliation_path, class: ("lct-active" if request.path.start_with?(reconciliation_path)) %>
26
+ <% end %>
22
27
  </nav>
23
28
  </header>
24
29
 
25
- <%= yield %>
30
+ <%= body %>
26
31
  </main>
27
32
  </div>
28
33
  </body>
@@ -2,83 +2,16 @@
2
2
  <div class="lct-toolbar-head">
3
3
  <h2 class="lct-section-title">Calls</h2>
4
4
  <div class="lct-toolbar-actions">
5
- <%= link_to "Export CSV", calls_path(current_query(format: :csv)), class: "lct-button lct-button-secondary" %>
5
+ <%= link_to "Export CSV",
6
+ calls_path(current_query(format: :csv)),
7
+ class: "lct-button lct-button-secondary",
8
+ title: "Capped at #{number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT)} rows per request — narrow the date range to export larger slices." %>
6
9
  </div>
7
10
  </div>
8
11
 
9
- <form class="lct-filters" action="<%= calls_path %>" method="get">
10
- <div class="lct-filter-row lct-filter-row-with-sort">
11
- <div class="lct-field">
12
- <label for="lct-from">From</label>
13
- <input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
14
- </div>
15
-
16
- <div class="lct-field">
17
- <label for="lct-to">To</label>
18
- <input id="lct-to" type="date" name="to" value="<%= params[:to] %>">
19
- </div>
20
-
21
- <div class="lct-field">
22
- <label for="lct-provider">Provider</label>
23
- <%= select_tag :provider,
24
- options_for_select(provider_filter_options, params[:provider]),
25
- include_blank: "All providers",
26
- id: "lct-provider" %>
27
- </div>
28
-
29
- <div class="lct-field">
30
- <label for="lct-model">Model</label>
31
- <%= select_tag :model,
32
- options_for_select(model_filter_options, params[:model]),
33
- include_blank: "All models",
34
- id: "lct-model" %>
35
- </div>
36
-
37
- <div class="lct-field">
38
- <label for="lct-stream">Stream</label>
39
- <%= select_tag :stream,
40
- options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
41
- include_blank: "All calls",
42
- id: "lct-stream" %>
43
- </div>
44
-
45
- <div class="lct-field">
46
- <label for="lct-sort">Sort</label>
47
- <%= select_tag :sort,
48
- options_for_select(
49
- [["Recent first", ""],
50
- ["Most expensive", "expensive"],
51
- ["Largest input", "input"],
52
- ["Largest output", "output"],
53
- ["Slowest", "slow"],
54
- ["Unknown pricing only", "unknown_pricing"]],
55
- @sort
56
- ),
57
- id: "lct-sort" %>
58
- </div>
59
-
60
- <div class="lct-filter-actions">
61
- <button class="lct-button" type="submit">Apply</button>
62
- <%= link_to("Reset", calls_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
63
- </div>
64
- </div>
65
- </form>
12
+ <%= render "llm_cost_tracker/shared/filters", path: calls_path %>
66
13
 
67
14
  <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: calls_path %>
68
-
69
- <p class="lct-summary-row">
70
- <span class="lct-pagination-per">
71
- <span class="lct-pagination-per-label">Per page:</span>
72
- <% LlmCostTracker::PaginationHelper::PER_PAGE_CHOICES.each do |choice| %>
73
- <% if choice == @page.per %>
74
- <span class="lct-pagination-per-option is-active" aria-current="true"><%= choice %></span>
75
- <% else %>
76
- <%= link_to choice, calls_path(current_query(per: choice, page: 1)), class: "lct-pagination-per-option" %>
77
- <% end %>
78
- <% end %>
79
- </span>
80
- </p>
81
- <p class="lct-toolbar-note">CSV export is capped at <%= number(LlmCostTracker::CallsController::CSV_EXPORT_LIMIT) %> rows per request — narrow the date range to export larger slices.</p>
82
15
  </section>
83
16
 
84
17
  <% if @calls_count.zero? %>
@@ -91,6 +24,31 @@
91
24
  </section>
92
25
  <% else %>
93
26
  <section class="lct-panel">
27
+ <div class="lct-results-toolbar">
28
+ <span class="lct-pagination-per">
29
+ <span class="lct-pagination-per-label">Per page:</span>
30
+ <% LlmCostTracker::PaginationHelper::PER_PAGE_CHOICES.each do |choice| %>
31
+ <% if choice == @page.per %>
32
+ <span class="lct-pagination-per-option is-active" aria-current="true"><%= choice %></span>
33
+ <% else %>
34
+ <%= link_to choice, calls_path(current_query(per: choice, page: 1)), class: "lct-pagination-per-option" %>
35
+ <% end %>
36
+ <% end %>
37
+ </span>
38
+
39
+ <%= render "llm_cost_tracker/shared/sort",
40
+ current: @sort,
41
+ options: [
42
+ ["Recent", ""],
43
+ ["Most expensive", "expensive"],
44
+ ["Largest input", "input"],
45
+ ["Largest output", "output"],
46
+ ["Slowest", "slow"],
47
+ ["Unknown pricing", "unknown_pricing"]
48
+ ],
49
+ path_for_sort: ->(value) { calls_path(current_query(sort: value.presence, page: nil)) } %>
50
+ </div>
51
+
94
52
  <div class="lct-table-wrap">
95
53
  <table class="lct-table lct-table-compact lct-calls-table">
96
54
  <thead>
@@ -113,9 +71,9 @@
113
71
  <td class="lct-nowrap"><%= format_date(call.tracked_at) %></td>
114
72
  <td><%= call.provider %></td>
115
73
  <td><code class="lct-code"><%= call.model %></code></td>
116
- <td class="lct-num"><%= format_tokens(call.input_tokens) %></td>
117
- <td class="lct-num"><%= format_tokens(call.output_tokens) %></td>
118
- <td class="lct-num"><%= format_tokens(call.total_tokens) %></td>
74
+ <td class="lct-num"><%= number(call.input_tokens) %></td>
75
+ <td class="lct-num"><%= number(call.output_tokens) %></td>
76
+ <td class="lct-num"><%= number(call.total_tokens) %></td>
119
77
  <td class="lct-num<%= ' lct-num-muted' if call.total_cost.nil? %>"><%= optional_money(call.total_cost) %></td>
120
78
  <td class="lct-num<%= ' lct-num-muted' if call.latency_ms.nil? %>"><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
121
79
  <td><%= render "llm_cost_tracker/shared/tag_chips", tags: call.parsed_tags %></td>
@@ -1,28 +1,32 @@
1
1
  <% priced_components = token_usage_stack_components %>
2
+ <% line_item_costs_by_component = call_line_item_costs_by_component(@call) %>
2
3
  <% token_segments = priced_components.map do |component|
3
4
  token_key = component.fetch(:token_key)
4
5
  value = @call.has_attribute?(token_key) ? @call[token_key] : 0
5
- { label: component.fetch(:label), value: value, formatted_value: format_tokens(value), css_class: component.fetch(:css_class) }
6
+ { label: component.fetch(:label), value: value, formatted_value: number(value), css_class: component.fetch(:css_class) }
6
7
  end %>
7
8
  <% cost_segments = [] %>
8
9
  <% unless @call.total_cost.nil? %>
9
10
  <% cost_segments = priced_components.map do |component|
10
- cost_key = component.fetch(:cost_key)
11
- value = @call.has_attribute?(cost_key) ? @call[cost_key] : nil
11
+ value = line_item_costs_by_component[component.fetch(:price_key)]
12
12
  { label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
13
13
  end %>
14
14
  <% end %>
15
15
 
16
16
  <section class="lct-panel">
17
- <p class="lct-muted"><%= link_to "Back to calls", calls_path %></p>
17
+ <nav class="lct-breadcrumb" aria-label="Breadcrumb">
18
+ <%= link_to "Calls", calls_path, class: "lct-breadcrumb-link" %>
19
+ <span class="lct-breadcrumb-sep" aria-hidden="true">›</span>
20
+ <span class="lct-breadcrumb-current">#<%= @call.id %></span>
21
+ </nav>
18
22
  <div class="lct-call-hero">
19
23
  <div>
20
- <h2 class="lct-section-title lct-call-title">Call #<%= @call.id %></h2>
24
+ <h2 class="lct-section-title lct-call-title">
25
+ <code class="lct-code"><%= @call.model %></code>
26
+ </h2>
21
27
  <p class="lct-call-subtitle">
22
28
  <code class="lct-code"><%= @call.provider %></code>
23
29
  <span>·</span>
24
- <code class="lct-code"><%= @call.model %></code>
25
- <span>·</span>
26
30
  <span><%= format_date(@call.tracked_at) %></span>
27
31
  </p>
28
32
  </div>
@@ -38,7 +42,7 @@ end %>
38
42
  </div>
39
43
  <div class="lct-call-summary-item">
40
44
  <span class="lct-call-summary-label">Total tokens</span>
41
- <strong><%= format_tokens(@call.total_tokens) %></strong>
45
+ <strong><%= number(@call.total_tokens) %></strong>
42
46
  </div>
43
47
  <div class="lct-call-summary-item">
44
48
  <span class="lct-call-summary-label">Latency</span>
@@ -65,55 +69,91 @@ end %>
65
69
 
66
70
  <div class="lct-detail-grid">
67
71
  <dl class="lct-dl">
68
- <dt>Tracked At</dt>
69
- <dd><%= format_date(@call.tracked_at) %></dd>
72
+ <dt>Cost Status</dt>
73
+ <dd><%= @call.cost_status.presence || "n/a" %></dd>
70
74
 
71
- <dt>Provider</dt>
72
- <dd><%= @call.provider %></dd>
73
-
74
- <dt>Model</dt>
75
- <dd><%= @call.model %></dd>
75
+ <dt>Latency</dt>
76
+ <dd><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></dd>
76
77
 
77
- <dt>Pricing Status</dt>
78
- <dd><%= pricing_status(@call) %></dd>
78
+ <dt>Batch</dt>
79
+ <dd><%= @call.batch? ? "yes" : "no" %></dd>
79
80
 
80
- <dt>Provider Response ID</dt>
81
+ <dt>Response ID</dt>
81
82
  <dd><%= @call.provider_response_id.presence || "n/a" %></dd>
82
83
 
83
- <% if @call.has_attribute?("created_at") %>
84
- <dt>Created At</dt>
85
- <dd><%= format_date(@call.created_at) %></dd>
86
- <% end %>
84
+ <dt>Project ID</dt>
85
+ <dd><%= @call.provider_project_id.present? ? LlmCostTracker::Masking.mask_value(:provider_project_id, @call.provider_project_id) : "n/a" %></dd>
87
86
 
88
- <% if @call.has_attribute?("updated_at") %>
89
- <dt>Updated At</dt>
90
- <dd><%= format_date(@call.updated_at) %></dd>
91
- <% end %>
87
+ <dt>API Key ID</dt>
88
+ <dd><%= @call.provider_api_key_id.present? ? LlmCostTracker::Masking.mask_value(:provider_api_key_id, @call.provider_api_key_id) : "n/a" %></dd>
89
+
90
+ <dt>Workspace ID</dt>
91
+ <dd><%= @call.provider_workspace_id.present? ? LlmCostTracker::Masking.mask_value(:provider_workspace_id, @call.provider_workspace_id) : "n/a" %></dd>
92
92
  </dl>
93
93
 
94
94
  <dl class="lct-dl">
95
95
  <% priced_components.each do |component| %>
96
96
  <dt><%= component.fetch(:label).titleize %> Tokens</dt>
97
- <dd><%= format_tokens(@call[component.fetch(:token_key)]) %></dd>
97
+ <dd><%= number(@call[component.fetch(:token_key)]) %></dd>
98
98
  <% end %>
99
99
 
100
100
  <dt>Total Tokens</dt>
101
- <dd><%= format_tokens(@call.total_tokens) %></dd>
101
+ <dd><%= number(@call.total_tokens) %></dd>
102
+ </dl>
102
103
 
104
+ <dl class="lct-dl">
103
105
  <% priced_components.each do |component| %>
104
106
  <dt><%= component.fetch(:label).titleize %> Cost</dt>
105
- <dd><%= optional_money(@call[component.fetch(:cost_key)]) %></dd>
107
+ <dd><%= optional_money(line_item_costs_by_component[component.fetch(:price_key)]) %></dd>
106
108
  <% end %>
107
109
 
108
110
  <dt>Total Cost</dt>
109
111
  <dd><%= optional_money(@call.total_cost) %></dd>
110
-
111
- <dt>Latency</dt>
112
- <dd><%= @call.latency_ms ? "#{optional_number(@call.latency_ms)}ms" : "n/a" %></dd>
113
112
  </dl>
114
113
  </div>
115
114
  </section>
116
115
 
116
+ <% service_line_items = @call.line_items.where.not(unit: "token").order(:position).to_a %>
117
+ <% if service_line_items.any? %>
118
+ <section class="lct-panel">
119
+ <h2 class="lct-section-title">Service Charges</h2>
120
+ <div class="lct-table-wrap">
121
+ <table class="lct-table lct-table-compact">
122
+ <thead>
123
+ <tr>
124
+ <th>Component</th>
125
+ <th>Unit</th>
126
+ <th class="lct-num">Quantity</th>
127
+ <th class="lct-num">Rate</th>
128
+ <th class="lct-num">Cost</th>
129
+ <th>Status</th>
130
+ </tr>
131
+ </thead>
132
+ <tbody>
133
+ <% service_line_items.each do |line_item| %>
134
+ <tr>
135
+ <td><code class="lct-code"><%= line_item.kind %></code></td>
136
+ <td><%= line_item.unit %></td>
137
+ <td class="lct-num"><%= line_item.quantity %></td>
138
+ <td class="lct-num"><%= line_item.rate_amount ? "#{optional_money(line_item.rate_amount)} / #{line_item.rate_quantity}" : "n/a" %></td>
139
+ <% unknown_cost = line_item.cost.nil? || line_item.cost_status.to_s == LlmCostTracker::Billing::CostStatus::UNKNOWN %>
140
+ <td class="lct-num<%= ' lct-num-muted' if unknown_cost %>"><%= unknown_cost ? "n/a" : money(line_item.cost) %></td>
141
+ <td><%= line_item.cost_status %></td>
142
+ </tr>
143
+ <% end %>
144
+ </tbody>
145
+ </table>
146
+ </div>
147
+ </section>
148
+ <% end %>
149
+
150
+ <% if @call.pricing_snapshot.present? %>
151
+ <section class="lct-panel">
152
+ <h2 class="lct-section-title">Pricing Snapshot</h2>
153
+ <pre class="lct-pre"><%= safe_json(@call.pricing_snapshot) %></pre>
154
+ </section>
155
+ <% end %>
156
+
117
157
  <section class="lct-panel">
118
158
  <h2 class="lct-section-title">Tags</h2>
119
159
  <pre class="lct-pre"><%= safe_json(@call.parsed_tags) %></pre>
@@ -122,6 +162,6 @@ end %>
122
162
  <% if @call.has_attribute?("metadata") %>
123
163
  <section class="lct-panel">
124
164
  <h2 class="lct-section-title">Metadata</h2>
125
- <pre class="lct-pre"><%= safe_json(@call.read_attribute("metadata")) %></pre>
165
+ <pre class="lct-pre"><%= safe_json(LlmCostTracker::Masking.mask_hash(masked_metadata_hash(@call.read_attribute("metadata")))) %></pre>
126
166
  </section>
127
167
  <% end %>
@@ -1,48 +1,10 @@
1
1
  <% overview_filter_scope = current_query(from: @from_date.iso8601, to: @to_date.iso8601) %>
2
2
 
3
3
  <section class="lct-panel lct-toolbar">
4
- <form class="lct-filters" action="<%= root_path %>" method="get">
5
- <div class="lct-filter-row lct-filter-row-basic">
6
- <div class="lct-field">
7
- <label for="lct-overview-from">From</label>
8
- <input id="lct-overview-from" type="date" name="from" value="<%= params[:from] || @from_date.iso8601 %>">
9
- </div>
10
-
11
- <div class="lct-field">
12
- <label for="lct-overview-to">To</label>
13
- <input id="lct-overview-to" type="date" name="to" value="<%= params[:to] || @to_date.iso8601 %>">
14
- </div>
15
-
16
- <div class="lct-field">
17
- <label for="lct-overview-provider">Provider</label>
18
- <%= select_tag :provider,
19
- options_for_select(provider_filter_options(filter_params: overview_filter_scope), params[:provider]),
20
- include_blank: "All providers",
21
- id: "lct-overview-provider" %>
22
- </div>
23
-
24
- <div class="lct-field">
25
- <label for="lct-overview-model">Model</label>
26
- <%= select_tag :model,
27
- options_for_select(model_filter_options(filter_params: overview_filter_scope), params[:model]),
28
- include_blank: "All models",
29
- id: "lct-overview-model" %>
30
- </div>
31
-
32
- <div class="lct-field">
33
- <label for="lct-overview-stream">Stream</label>
34
- <%= select_tag :stream,
35
- options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
36
- include_blank: "All calls",
37
- id: "lct-overview-stream" %>
38
- </div>
39
-
40
- <div class="lct-filter-actions">
41
- <button class="lct-button" type="submit">Apply</button>
42
- <%= link_to("Reset", root_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
43
- </div>
44
- </div>
45
- </form>
4
+ <%= render "llm_cost_tracker/shared/filters",
5
+ path: root_path,
6
+ filter_scope: overview_filter_scope,
7
+ defaults: { from: @from_date.iso8601, to: @to_date.iso8601 } %>
46
8
 
47
9
  <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: root_path %>
48
10
  </section>
@@ -52,7 +14,7 @@
52
14
  <h2 class="lct-state-title">No LLM calls yet</h2>
53
15
  <p class="lct-state-copy">Tracked requests will appear here after your application records its first LLM API call.</p>
54
16
  <div class="lct-state-actions">
55
- <%= link_to "View calls", calls_path, class: "lct-button lct-button-secondary" %>
17
+ <%= link_to "Calls", calls_path, class: "lct-button lct-button-secondary" %>
56
18
  </div>
57
19
  </section>
58
20
  <% else %>
@@ -84,7 +46,7 @@
84
46
  <% end %>
85
47
  </p>
86
48
  </div>
87
- <%= link_to "Review calls →",
49
+ <%= link_to "Calls",
88
50
  calls_path(current_query(provider: @spend_anomaly.fetch(:provider), model: @spend_anomaly.fetch(:model), from: @to_date.iso8601, to: @to_date.iso8601, page: nil, per: nil, format: nil)),
89
51
  class: "lct-button lct-button-secondary" %>
90
52
  </aside>
@@ -102,6 +64,11 @@
102
64
 
103
65
  <div class="lct-hero-side">
104
66
  <div class="lct-stat-grid">
67
+ <article class="lct-stat">
68
+ <p class="lct-stat-label">Avg cost / call</p>
69
+ <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
70
+ </article>
71
+
105
72
  <article class="lct-stat">
106
73
  <p class="lct-stat-label">Calls</p>
107
74
  <p class="lct-stat-value"><%= number(@stats.total_calls) %></p>
@@ -109,11 +76,6 @@
109
76
  <span class="<%= badge[:css_class] %>"><span class="lct-delta-dot" aria-hidden="true"></span><%= badge[:text] %></span>
110
77
  </article>
111
78
 
112
- <article class="lct-stat">
113
- <p class="lct-stat-label">Avg cost / call</p>
114
- <p class="lct-stat-value"><%= money(@stats.average_cost_per_call) %></p>
115
- </article>
116
-
117
79
  <% if @stats.average_latency_ms %>
118
80
  <article class="lct-stat">
119
81
  <p class="lct-stat-label">Avg latency</p>
@@ -125,9 +87,6 @@
125
87
 
126
88
  <% if @monthly_budget_status %>
127
89
  <% budget = @monthly_budget_status %>
128
- <% fill_mod = budget_fill_modifier(budget[:percent_used]) %>
129
- <% projected_marker = [[budget[:projected_percent_used].to_f, 0.0].max, 100.0].min %>
130
- <% projected_delta = budget[:projected_delta].to_f %>
131
90
  <section class="lct-panel lct-panel-tight">
132
91
  <div class="lct-section-head">
133
92
  <div>
@@ -143,16 +102,16 @@
143
102
  </span>
144
103
  <span class="lct-budget-percent"><%= percent(budget[:percent_used]) %></span>
145
104
  </div>
146
- <div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= [budget[:percent_used].to_f, 100.0].min.round %>">
147
- <div class="lct-budget-fill <%= fill_mod %>" style="width: <%= [budget[:percent_used].to_f, 100.0].min %>%"></div>
105
+ <div class="lct-budget-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= budget[:progress_percent].round %>">
106
+ <div data-lct-style="<%= inline_style("width: #{budget[:progress_percent]}%") %>" class="lct-budget-fill <%= budget[:fill_modifier] %>"></div>
148
107
  <% if budget[:projected_spent].positive? %>
149
- <span class="lct-budget-marker" aria-hidden="true" style="left: calc(<%= projected_marker %>% - 1px)"></span>
108
+ <span data-lct-style="<%= inline_style("left: calc(#{budget[:projected_marker_percent]}% - 1px)") %>" class="lct-budget-marker" aria-hidden="true"></span>
150
109
  <% end %>
151
110
  </div>
152
111
  <p class="lct-budget-projection">
153
112
  <span>Projected <strong><%= money(budget[:projected_spent]) %></strong> by <%= budget[:projection_end_label] %></span>
154
- <span class="lct-budget-projection-status <%= projected_delta.positive? ? "lct-budget-projection-status--over" : "lct-budget-projection-status--under" %>">
155
- <%= money(projected_delta.abs) %> <%= projected_delta.positive? ? "over" : "under" %> budget
113
+ <span class="lct-budget-projection-status <%= budget[:projected_delta_status_class] %>">
114
+ <%= money(budget[:projected_delta_amount]) %> <%= budget[:projected_delta_direction] %> budget
156
115
  </span>
157
116
  </p>
158
117
  <p class="lct-budget-meta">Soft limit: blocking is not atomic under concurrency.</p>