llm_cost_tracker 0.14.1 → 0.15.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -5,13 +5,11 @@ module LlmCostTracker
5
5
  MAX_ROWS = 200
6
6
 
7
7
  def index
8
- @sort = params[:sort].to_s
9
- @dir = params[:dir].to_s
10
8
  @rows = Dashboard::TopModels.call(
11
9
  scope: Dashboard::Filter.call(params: params),
12
10
  limit: MAX_ROWS,
13
- sort: @sort,
14
- direction: @dir
11
+ sort: params[:sort].to_s,
12
+ direction: params[:dir].to_s
15
13
  )
16
14
  end
17
15
  end
@@ -4,13 +4,24 @@ module LlmCostTracker
4
4
  class PricingController < ApplicationController
5
5
  def index
6
6
  @overview = Dashboard::PricingOverview.call
7
- requested = params[:source].to_s.to_sym
8
- @active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
7
+ @active_source = requested_source || @overview.fetch(:effective_source)
9
8
  @source_data = @overview.fetch(:sources).fetch(@active_source)
10
9
  @provider_filter = Dashboard::Params.scalar(params[:provider], :provider).presence
11
- @rows = @source_data.fetch(:rows)
12
- @rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
13
10
  @providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
11
+ @rows = provider_rows(@source_data.fetch(:rows))
12
+ end
13
+
14
+ private
15
+
16
+ def requested_source
17
+ source = params[:source].to_s.to_sym
18
+ source if @overview.fetch(:sources).key?(source)
19
+ end
20
+
21
+ def provider_rows(rows)
22
+ return rows unless @provider_filter
23
+
24
+ rows.select { |row| row.provider == @provider_filter }
14
25
  end
15
26
  end
16
27
  end
@@ -10,21 +10,29 @@ module LlmCostTracker
10
10
  @value = Dashboard::Params.scalar(params[:tag_value], :tag_value)
11
11
 
12
12
  if @value.empty?
13
- @sort = params[:sort].to_s
14
- @dir = params[:dir].to_s
15
- @breakdown = Dashboard::TagBreakdown.call(
16
- scope: Dashboard::Filter.call(params: params), key: params[:key], sort: @sort, direction: @dir
17
- )
13
+ assign_breakdown
18
14
  else
19
- @key = LlmCostTracker::Tags::Key.validate!(
20
- params[:key],
21
- error_class: LlmCostTracker::InvalidFilterError
22
- )
23
- value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
24
- @value_total_cost = value_scope.sum(:total_cost).to_f
25
- @value_calls = value_scope.count
26
- @value_points = Dashboard::TimeSeries.call(scope: value_scope)
15
+ assign_value_trend
27
16
  end
28
17
  end
18
+
19
+ private
20
+
21
+ def assign_breakdown
22
+ @breakdown = Dashboard::TagBreakdown.call(
23
+ scope: Dashboard::Filter.call(params: params),
24
+ key: params[:key],
25
+ sort: params[:sort].to_s,
26
+ direction: params[:dir].to_s
27
+ )
28
+ end
29
+
30
+ def assign_value_trend
31
+ @key = LlmCostTracker::Tags::Key.validate!(params[:key], error_class: LlmCostTracker::InvalidFilterError)
32
+ value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
33
+ @value_total_cost = value_scope.sum(:total_cost).to_f
34
+ @value_calls = value_scope.count
35
+ @value_points = Dashboard::TimeSeries.call(scope: value_scope, from: @from_date, to: @to_date)
36
+ end
29
37
  end
30
38
  end
@@ -6,6 +6,12 @@ module LlmCostTracker
6
6
  module ApplicationHelper
7
7
  TAG_VALUE_SUMMARY_BYTES = 80
8
8
  TAG_TOOLTIP_BYTES = 512
9
+ PRICING_STATUS_LABELS = {
10
+ LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
11
+ LlmCostTracker::Charges::CostStatus::FREE => "Free",
12
+ LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
13
+ }.freeze
14
+ NEUTRAL_DELTA_CLASS = "lct-delta-badge lct-delta-neutral"
9
15
 
10
16
  include DashboardFilterOptionsHelper
11
17
  include DashboardQueryHelper
@@ -13,6 +19,7 @@ module LlmCostTracker
13
19
  include PaginationHelper
14
20
  include TokenUsageHelper
15
21
  include InlineStyleHelper
22
+ include SortableTableHelper
16
23
 
17
24
  def dashboard_section
18
25
  path = request.path.to_s
@@ -25,11 +32,13 @@ module LlmCostTracker
25
32
  :overview
26
33
  end
27
34
 
28
- def coverage_percent(numerator, denominator)
29
- denominator = denominator.to_f
30
- return 0.0 unless denominator.positive?
35
+ def sidebar_link_options(section)
36
+ active = dashboard_section == section
37
+ { class: active ? "lct-sidebar-link lct-active" : "lct-sidebar-link", aria: active ? { current: "page" } : {} }
38
+ end
31
39
 
32
- (numerator.to_f / denominator) * 100.0
40
+ def coverage_percent(numerator, denominator)
41
+ Dashboard::Percent.of(numerator, denominator)
33
42
  end
34
43
 
35
44
  def money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
@@ -54,11 +63,7 @@ module LlmCostTracker
54
63
  def pricing_status(call)
55
64
  return "Unknown" if call.total_cost.nil?
56
65
 
57
- {
58
- LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
59
- LlmCostTracker::Charges::CostStatus::FREE => "Free",
60
- LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
61
- }.fetch(call.cost_status, "Unknown")
66
+ PRICING_STATUS_LABELS.fetch(call.cost_status, "Unknown")
62
67
  end
63
68
 
64
69
  def percent(value)
@@ -66,22 +71,11 @@ module LlmCostTracker
66
71
  end
67
72
 
68
73
  def delta_badge(delta_percent, mode: :cost)
69
- return { text: "n/a vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if delta_percent.nil?
70
-
71
- rounded = delta_percent.round(1)
72
- return { text: "0.0% vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if rounded.zero?
74
+ rounded = delta_percent&.round(1)
75
+ return { text: "n/a vs. prior", css_class: NEUTRAL_DELTA_CLASS } if rounded.nil?
76
+ return { text: "0.0% vs. prior", css_class: NEUTRAL_DELTA_CLASS } if rounded.zero?
73
77
 
74
- sign = rounded.positive? ? "+" : ""
75
- text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
76
- css_class = if mode == :neutral
77
- "lct-delta-badge lct-delta-neutral"
78
- elsif rounded.positive?
79
- "lct-delta-badge lct-delta-up"
80
- else
81
- "lct-delta-badge lct-delta-down"
82
- end
83
-
84
- { text: text, css_class: css_class }
78
+ { text: "#{format('%+.1f', rounded)}% vs. prior", css_class: delta_badge_class(rounded, mode) }
85
79
  end
86
80
 
87
81
  def bar_width(value, max)
@@ -120,13 +114,10 @@ module LlmCostTracker
120
114
  end
121
115
 
122
116
  def tag_chip_entries(tags, limit: 3)
123
- normalized = normalized_tags(tags)
124
- return [] if normalized.empty?
125
-
126
- visible = normalized.first(limit).map do |key, value|
127
- { key: key.to_s, value: tag_value_summary(value) }
117
+ visible = tags.first(limit).map do |key, value|
118
+ { key: key.to_s, value: truncate_text(value.to_s, TAG_VALUE_SUMMARY_BYTES) }
128
119
  end
129
- visible << { more: normalized.size - limit } if normalized.size > limit
120
+ visible << { more: tags.size - limit } if tags.size > limit
130
121
  visible
131
122
  end
132
123
 
@@ -134,33 +125,12 @@ module LlmCostTracker
134
125
  truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
135
126
  end
136
127
 
137
- def current_query(overrides = {})
138
- request.query_parameters.symbolize_keys.slice(*LlmCostTracker::Dashboard::Params::QUERY_KEYS).merge(overrides)
139
- end
140
-
141
- def calls_query_for_model(provider:, model:)
142
- current_query(provider: provider, model: model, page: nil, per: nil, format: nil)
143
- end
144
-
145
128
  private
146
129
 
147
- def normalized_tags(tags)
148
- return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
149
-
150
- JSON.parse(tags || "{}")
151
- rescue JSON::ParserError, TypeError
152
- {}
153
- end
154
-
155
- def tag_value_summary(value)
156
- string = case value
157
- when Hash, Array
158
- JSON.generate(value)
159
- else
160
- value.to_s
161
- end
130
+ def delta_badge_class(rounded, mode)
131
+ return NEUTRAL_DELTA_CLASS if mode == :neutral
162
132
 
163
- truncate_text(string, TAG_VALUE_SUMMARY_BYTES)
133
+ rounded.positive? ? "lct-delta-badge lct-delta-up" : "lct-delta-badge lct-delta-down"
164
134
  end
165
135
 
166
136
  def truncate_text(string, limit)
@@ -2,17 +2,27 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module ChartHelper
5
+ CHART_OPEN_TAG = "<svg class=\"lct-chart\" viewBox=\"0 0 %<width>s %<height>s\" " \
6
+ "preserveAspectRatio=\"xMidYMid meet\" role=\"img\" aria-label=\"Daily spend trend\">"
7
+ CHART_GRADIENT = "<defs><linearGradient id=\"lct-chart-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" \
8
+ "<stop offset=\"0%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.28\"/>" \
9
+ "<stop offset=\"100%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.02\"/>" \
10
+ "</linearGradient></defs>"
11
+
5
12
  def spend_chart_svg(points, comparison_points: nil, height: 180, y_ticks: 3)
6
13
  return nil if points.blank?
7
14
 
8
- cfg = chart_config(points, comparison_points, height, y_ticks)
9
- parts = [chart_svg_open(cfg), "<title>Daily spend trend</title>", chart_area_gradient_def]
10
- parts.concat(chart_grid_and_axis(cfg))
11
- parts << chart_paths(cfg)
12
- parts.concat(chart_dots(cfg))
13
- parts.concat(chart_x_labels(cfg))
14
- parts << "</svg>"
15
- parts.join.html_safe
15
+ chart = Dashboard::SpendChart.new(points, comparison_points: comparison_points, height: height, y_ticks: y_ticks)
16
+ [
17
+ format(CHART_OPEN_TAG, width: Dashboard::SpendChart::WIDTH, height: chart.height),
18
+ "<title>Daily spend trend</title>",
19
+ CHART_GRADIENT,
20
+ *(0..chart.y_ticks).map { |index| chart_tick(chart, index) },
21
+ chart_paths(chart),
22
+ *chart.coords.each_index.map { |index| chart_dot(chart, index) },
23
+ *chart.label_indexes.map { |index| chart_x_label(chart, index) },
24
+ "</svg>"
25
+ ].join.html_safe
16
26
  end
17
27
 
18
28
  private
@@ -21,129 +31,64 @@ module LlmCostTracker
21
31
  format("%.2f", value)
22
32
  end
23
33
 
24
- def chart_config(points, comparison_points, height, y_ticks)
25
- width = 1180
26
- pad = { top: 16, right: 16, bottom: 28, left: 56 }
27
- plot_w = width - pad[:left] - pad[:right]
28
- plot_h = height - pad[:top] - pad[:bottom]
29
- all_costs = points.map { |p| p[:cost].to_f } + Array(comparison_points).map { |p| p[:cost].to_f }
30
- max_cost = [all_costs.max.to_f, 0.0001].max
31
- coords = chart_coords(points, pad, plot_w, plot_h, max_cost)
32
- comparison_coords = chart_coords(comparison_points, pad, plot_w, plot_h, max_cost) if comparison_points.present?
33
-
34
- peak_index = points.each_with_index.max_by { |point, _| point[:cost].to_f }&.last
35
- { width: width, height: height, pad: pad, plot_w: plot_w, plot_h: plot_h,
36
- max_cost: max_cost, n: points.size, y_ticks: y_ticks, points: points, coords: coords,
37
- comparison_points: comparison_points, comparison_coords: comparison_coords,
38
- peak_index: peak_index }
39
- end
40
-
41
- def chart_coords(points, pad, plot_w, plot_h, max_cost)
42
- n = points.size
43
- step = n > 1 ? plot_w.to_f / (n - 1) : 0.0
44
-
45
- points.each_with_index.map do |point, idx|
46
- x = pad[:left] + (idx * step)
47
- y = pad[:top] + plot_h - ((point[:cost].to_f / max_cost) * plot_h)
48
- [x, y]
49
- end
50
- end
51
-
52
- def chart_svg_open(cfg)
53
- attrs = [
54
- %(class="lct-chart"),
55
- %(viewBox="0 0 #{cfg[:width]} #{cfg[:height]}"),
56
- %(preserveAspectRatio="xMidYMid meet"),
57
- %(role="img"),
58
- %(aria-label="Daily spend trend")
59
- ].join(" ")
60
- "<svg #{attrs}>"
61
- end
62
-
63
- def chart_grid_and_axis(cfg)
64
- (0..cfg[:y_ticks]).map { |i| chart_tick_line(cfg, i) }
34
+ def chart_element(name, attributes, content = nil)
35
+ attrs = attributes.map { |key, value| %( #{key}="#{value}") }.join
36
+ content.nil? ? "<#{name}#{attrs}/>" : "<#{name}#{attrs}>#{content}</#{name}>"
65
37
  end
66
38
 
67
- def chart_tick_line(cfg, idx)
68
- pad = cfg[:pad]
69
- right_x = chart_fmt(pad[:left] + cfg[:plot_w])
70
- left_x = chart_fmt(pad[:left])
71
- text_x = chart_fmt(pad[:left] - 8)
72
- value = cfg[:max_cost] * (cfg[:y_ticks] - idx).to_f / cfg[:y_ticks]
73
- y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]))
74
- label_y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]) + 3)
75
- grid = %(<line class="lct-chart-grid" x1="#{left_x}" x2="#{right_x}" y1="#{y}" y2="#{y}"/>)
76
- label = format("%.2f", value)
77
- text = %(<text class="lct-chart-axis" x="#{text_x}" y="#{label_y}" text-anchor="end">$#{label}</text>)
78
- "#{grid}#{text}"
39
+ def chart_tick(chart, index)
40
+ tick_y = chart.tick_y(index)
41
+ y = chart_fmt(tick_y)
42
+ grid = { class: "lct-chart-grid", x1: chart_fmt(chart.left), x2: chart_fmt(chart.right), y1: y, y2: y }
43
+ label = { class: "lct-chart-axis", x: chart_fmt(chart.left - 8), y: chart_fmt(tick_y + 3), "text-anchor": "end" }
44
+ chart_element(:line, grid) + chart_element(:text, label, "$#{chart_fmt(chart.tick_value(index))}")
79
45
  end
80
46
 
81
- def chart_paths(cfg)
82
- line = build_line_path(cfg[:coords])
83
- base_y = cfg[:pad][:top] + cfg[:plot_h]
84
- area = build_area_path(cfg[:coords], cfg, base_y, line)
85
- secondary = if cfg[:comparison_coords].present?
86
- %(<path class="lct-chart-line-secondary" d="#{build_line_path(cfg[:comparison_coords])}"/>)
87
- else
88
- ""
89
- end
90
- %(<path class="lct-chart-area" d="#{area}"/>#{secondary}<path class="lct-chart-line" d="#{line}"/>)
47
+ def chart_paths(chart)
48
+ line = chart_line_path(chart.coords)
49
+ paths = [chart_element(:path, class: "lct-chart-area", d: chart_area_path(chart, line))]
50
+ if chart.comparison_coords
51
+ paths << chart_element(:path, class: "lct-chart-line-secondary", d: chart_line_path(chart.comparison_coords))
52
+ end
53
+ paths << chart_element(:path, class: "lct-chart-line", d: line)
54
+ paths.join
91
55
  end
92
56
 
93
- def build_line_path(coords)
94
- coords.each_with_index.map do |(x, y), idx|
95
- "#{idx.zero? ? 'M' : 'L'}#{chart_fmt(x)},#{chart_fmt(y)}"
57
+ def chart_line_path(coords)
58
+ coords.each_with_index.map do |(x, y), index|
59
+ "#{index.zero? ? 'M' : 'L'}#{chart_fmt(x)},#{chart_fmt(y)}"
96
60
  end.join(" ")
97
61
  end
98
62
 
99
- def build_area_path(coords, cfg, base_y, line)
100
- if coords.size == 1
101
- x, y = coords.first
102
- left = chart_fmt(cfg[:pad][:left])
103
- right = chart_fmt(cfg[:pad][:left] + cfg[:plot_w])
104
- "M#{left},#{chart_fmt(base_y)} L#{chart_fmt(x)},#{chart_fmt(y)} L#{right},#{chart_fmt(base_y)} Z"
105
- else
106
- first_x = chart_fmt(coords.first[0])
107
- last_x = chart_fmt(coords.last[0])
108
- "#{line} L#{last_x},#{chart_fmt(base_y)} L#{first_x},#{chart_fmt(base_y)} Z"
109
- end
110
- end
63
+ def chart_area_path(chart, line)
64
+ coords = chart.coords
65
+ base = chart_fmt(chart.baseline)
66
+ return "#{line} L#{chart_fmt(coords.last[0])},#{base} L#{chart_fmt(coords.first[0])},#{base} Z" if coords.size > 1
111
67
 
112
- def chart_dots(cfg)
113
- cfg[:coords].each_with_index.map { |(pt_x, pt_y), idx| chart_dot(cfg, pt_x, pt_y, idx) }
68
+ x, y = coords.first
69
+ "M#{chart_fmt(chart.left)},#{base} L#{chart_fmt(x)},#{chart_fmt(y)} L#{chart_fmt(chart.right)},#{base} Z"
114
70
  end
115
71
 
116
- def chart_dot(cfg, pt_x, pt_y, idx)
117
- point = cfg[:points][idx]
72
+ def chart_dot(chart, index)
73
+ point = chart.points[index]
74
+ x, y = chart.coords[index]
75
+ peak = index == chart.peak_index
76
+ circle = { class: peak ? "lct-chart-peak" : "lct-chart-dot", cx: chart_fmt(x), cy: chart_fmt(y), r: peak ? 4 : 3 }
118
77
  title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
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}"/>)
123
- "<g>#{circle}<title>#{title}</title></g>"
78
+ "<g>#{chart_element(:circle, circle)}<title>#{title}</title></g>"
124
79
  end
125
80
 
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>"
81
+ def chart_x_label(chart, index)
82
+ x, = chart.coords[index]
83
+ anchor = chart_label_anchor(chart, index)
84
+ attributes = { class: "lct-chart-axis", x: chart_fmt(x), y: chart_fmt(chart.height - 8), "text-anchor": anchor }
85
+ chart_element(:text, attributes, ERB::Util.html_escape(chart.points[index][:label]))
131
86
  end
132
87
 
133
- def chart_x_labels(cfg)
134
- indexes = cfg[:n] <= 2 ? (0...cfg[:n]).to_a : [0, cfg[:n] / 2, cfg[:n] - 1].uniq
135
- label_y = chart_fmt(cfg[:height] - 8)
136
- indexes.map { |idx| chart_x_label(cfg, idx, label_y) }
137
- end
88
+ def chart_label_anchor(chart, index)
89
+ return "start" if index.zero?
138
90
 
139
- def chart_x_label(cfg, idx, label_y)
140
- pt_x, = cfg[:coords][idx]
141
- label = ERB::Util.html_escape(cfg[:points][idx][:label])
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>)
91
+ index == chart.points.size - 1 ? "end" : "middle"
147
92
  end
148
93
  end
149
94
  end
@@ -2,23 +2,33 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module DashboardQueryHelper
5
- def dashboard_filter_path(query)
6
- cleaned = clean_dashboard_query(query)
7
- return request.path if cleaned.blank?
5
+ EMPTY_QUERY_VALUES = [nil, {}, []].freeze
8
6
 
9
- "#{request.path}?#{cleaned.to_query}"
7
+ def current_query(overrides = {})
8
+ request.query_parameters.symbolize_keys.slice(*Dashboard::Params::QUERY_KEYS).merge(overrides)
9
+ end
10
+
11
+ def calls_query_for_model(provider:, model:)
12
+ current_query(provider: provider, model: model, page: nil, per: nil, format: nil)
10
13
  end
11
14
 
12
15
  def calls_query_for_tag(key:, value:)
13
16
  query = current_query(page: nil, per: nil, format: nil)
14
- tags = LlmCostTracker::Dashboard::Params.tag_query(query[:tag])
17
+ tags = Dashboard::Params.tag_query(query[:tag])
15
18
  query[:tag] = tags.merge(key.to_s => value.to_s)
16
19
  query
17
20
  end
18
21
 
19
22
  def tag_drilldown_allowed?(key)
20
- tags = LlmCostTracker::Dashboard::Params.tag_query(current_query[:tag])
21
- tags.except(key.to_s).size < LlmCostTracker::Dashboard::Filter::MAX_TAG_FILTERS
23
+ tags = Dashboard::Params.tag_query(current_query[:tag])
24
+ tags.except(key.to_s).size < Dashboard::Filter::MAX_TAG_FILTERS
25
+ end
26
+
27
+ def dashboard_filter_path(query)
28
+ cleaned = clean_dashboard_query(query)
29
+ return request.path if cleaned.blank?
30
+
31
+ "#{request.path}?#{cleaned.to_query}"
22
32
  end
23
33
 
24
34
  def hidden_query_fields(query, prefix: nil)
@@ -35,19 +45,22 @@ module LlmCostTracker
35
45
  private
36
46
 
37
47
  def clean_dashboard_query(value)
38
- if value.is_a?(Hash) || value.try(:to_unsafe_h).is_a?(Hash)
39
- return LlmCostTracker::Dashboard::Params.to_hash(value).each_with_object({}) do |(key, nested), cleaned|
40
- nested = clean_dashboard_query(nested)
41
- next if nested.nil? || nested == {} || nested == []
42
-
43
- cleaned[key] = nested
44
- end
48
+ case value
49
+ when Array then value.filter_map { |item| clean_dashboard_query(item) }.presence
50
+ when String then value.strip.presence
51
+ else query_hash?(value) ? clean_query_hash(value) : value
45
52
  end
53
+ end
46
54
 
47
- return value.filter_map { |item| clean_dashboard_query(item) }.presence if value.is_a?(Array)
48
- return value.strip.presence if value.is_a?(String)
55
+ def clean_query_hash(hash)
56
+ Dashboard::Params.to_hash(hash).each_with_object({}) do |(key, value), cleaned|
57
+ value = clean_dashboard_query(value)
58
+ cleaned[key] = value unless EMPTY_QUERY_VALUES.include?(value)
59
+ end
60
+ end
49
61
 
50
- value
62
+ def query_hash?(value)
63
+ value.is_a?(Hash) || value.try(:to_unsafe_h).is_a?(Hash)
51
64
  end
52
65
  end
53
66
  end
@@ -2,40 +2,33 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module SortableTableHelper
5
+ SORT_ARROWS = { "asc" => "▲", "desc" => "▼" }.freeze
6
+ ARIA_SORT = { "asc" => "ascending", "desc" => "descending" }.freeze
7
+
5
8
  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")]) }
9
+ natural = num ? "desc" : "asc"
10
+ direction = sorted_direction(column, natural, default)
11
+ classes = ["lct-sortable", ("lct-num" if num), ("lct-sorted" if direction)].compact
12
+
13
+ href = dashboard_filter_path(current_query(sort: column, dir: next_sort_direction(direction, natural), page: nil))
14
+ tag.th(class: classes.join(" "), "aria-sort": ARIA_SORT.fetch(direction, "none")) do
15
+ link_to(href) { safe_join([label, " ", tag.span(SORT_ARROWS.fetch(direction, "▼"), class: "lct-sort-ind")]) }
14
16
  end
15
17
  end
16
18
 
17
19
  private
18
20
 
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
- }
21
+ def sorted_direction(column, natural, default)
22
+ return unless (params[:sort].presence || (column if default)) == column
23
+
24
+ requested = params[:dir].to_s
25
+ Dashboard::Sort::DIRECTIONS.include?(requested) ? requested : natural
33
26
  end
34
27
 
35
- def sortable_aria(active, effective_dir)
36
- return "none" unless active
28
+ def next_sort_direction(direction, natural)
29
+ return natural unless direction
37
30
 
38
- effective_dir == "asc" ? "ascending" : "descending"
31
+ direction == "asc" ? "desc" : "asc"
39
32
  end
40
33
  end
41
34
  end
@@ -33,41 +33,51 @@ module LlmCostTracker
33
33
  }.freeze
34
34
 
35
35
  def token_usage_stack_components
36
- token_usage_display_components(labels: COMPONENT_LABELS).select do |component|
37
- component.fetch(:cost_key)
36
+ LlmCostTracker::Usage::Catalog.token_priced.map do |component|
37
+ token_key = component.token_key
38
+ {
39
+ token_key: token_key,
40
+ price_key: component.key,
41
+ label: COMPONENT_LABELS.fetch(token_key),
42
+ css_class: STACK_CLASSES[token_key]
43
+ }
38
44
  end
39
45
  end
40
46
 
41
47
  def call_line_item_costs_by_component(call)
42
- call.line_items.each_with_object({}) do |line_item, accumulator|
43
- component = LlmCostTracker::Usage::Catalog.token_priced_for(
44
- kind: line_item.kind, direction: line_item.direction, cache_state: line_item.cache_state
45
- )
46
- accumulator[component.key] = line_item.cost if component && line_item.cost
48
+ LlmCostTracker::Usage::Catalog.costs_by_component(
49
+ call.line_items.map { |item| [item.kind, item.direction, item.cache_state, item.cost] }
50
+ )
51
+ end
52
+
53
+ def call_token_segments(call)
54
+ token_usage_stack_components.map do |component|
55
+ value = call[component.fetch(:token_key)]
56
+ token_stack_segment(component, value, number_with_delimiter(value))
57
+ end
58
+ end
59
+
60
+ def call_cost_segments(call)
61
+ costs = call_line_item_costs_by_component(call)
62
+ token_usage_stack_components.map do |component|
63
+ value = costs[component.fetch(:price_key)]
64
+ token_stack_segment(component, value, optional_money(value))
47
65
  end
48
66
  end
49
67
 
68
+ def call_service_line_items(call)
69
+ call.line_items.reject { |item| item.unit == "token" }.sort_by(&:position)
70
+ end
71
+
50
72
  private
51
73
 
52
- def token_usage_display_components(labels:)
53
- LlmCostTracker::Usage::Catalog.token_priced.map do |component|
54
- token_key = component.token_key
55
- {
56
- token_key: token_key,
57
- cost_key: component.cost_key,
58
- price_key: component.key,
59
- label: labels.fetch(token_key),
60
- css_class: STACK_CLASSES[token_key]
61
- }
62
- end + [
63
- {
64
- token_key: :hidden_output_tokens,
65
- cost_key: nil,
66
- price_key: nil,
67
- label: labels.fetch(:hidden_output_tokens),
68
- css_class: nil
69
- }
70
- ]
74
+ def token_stack_segment(component, value, formatted_value)
75
+ {
76
+ label: component.fetch(:label),
77
+ value: value,
78
+ formatted_value: formatted_value,
79
+ css_class: component.fetch(:css_class)
80
+ }
71
81
  end
72
82
  end
73
83
  end