llm_cost_tracker 0.14.2 → 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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -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
@@ -26,11 +32,13 @@ module LlmCostTracker
26
32
  :overview
27
33
  end
28
34
 
29
- def coverage_percent(numerator, denominator)
30
- denominator = denominator.to_f
31
- 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
32
39
 
33
- (numerator.to_f / denominator) * 100.0
40
+ def coverage_percent(numerator, denominator)
41
+ Dashboard::Percent.of(numerator, denominator)
34
42
  end
35
43
 
36
44
  def money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
@@ -55,11 +63,7 @@ module LlmCostTracker
55
63
  def pricing_status(call)
56
64
  return "Unknown" if call.total_cost.nil?
57
65
 
58
- {
59
- LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
60
- LlmCostTracker::Charges::CostStatus::FREE => "Free",
61
- LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
62
- }.fetch(call.cost_status, "Unknown")
66
+ PRICING_STATUS_LABELS.fetch(call.cost_status, "Unknown")
63
67
  end
64
68
 
65
69
  def percent(value)
@@ -67,22 +71,11 @@ module LlmCostTracker
67
71
  end
68
72
 
69
73
  def delta_badge(delta_percent, mode: :cost)
70
- return { text: "n/a vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if delta_percent.nil?
71
-
72
- rounded = delta_percent.round(1)
73
- return { text: "0.0% vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if rounded.zero?
74
-
75
- sign = rounded.positive? ? "+" : ""
76
- text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
77
- css_class = if mode == :neutral
78
- "lct-delta-badge lct-delta-neutral"
79
- elsif rounded.positive?
80
- "lct-delta-badge lct-delta-up"
81
- else
82
- "lct-delta-badge lct-delta-down"
83
- end
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?
84
77
 
85
- { text: text, css_class: css_class }
78
+ { text: "#{format('%+.1f', rounded)}% vs. prior", css_class: delta_badge_class(rounded, mode) }
86
79
  end
87
80
 
88
81
  def bar_width(value, max)
@@ -132,15 +125,13 @@ module LlmCostTracker
132
125
  truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
133
126
  end
134
127
 
135
- def current_query(overrides = {})
136
- request.query_parameters.symbolize_keys.slice(*LlmCostTracker::Dashboard::Params::QUERY_KEYS).merge(overrides)
137
- end
128
+ private
138
129
 
139
- def calls_query_for_model(provider:, model:)
140
- current_query(provider: provider, model: model, page: nil, per: nil, format: nil)
141
- end
130
+ def delta_badge_class(rounded, mode)
131
+ return NEUTRAL_DELTA_CLASS if mode == :neutral
142
132
 
143
- private
133
+ rounded.positive? ? "lct-delta-badge lct-delta-up" : "lct-delta-badge lct-delta-down"
134
+ end
144
135
 
145
136
  def truncate_text(string, limit)
146
137
  return string if string.bytesize <= 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((0..cfg[:y_ticks]).map { |i| chart_tick_line(cfg, i) })
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,125 +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_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}>"
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}>"
61
37
  end
62
38
 
63
- def chart_tick_line(cfg, idx)
64
- pad = cfg[:pad]
65
- right_x = chart_fmt(pad[:left] + cfg[:plot_w])
66
- left_x = chart_fmt(pad[:left])
67
- text_x = chart_fmt(pad[:left] - 8)
68
- value = cfg[:max_cost] * (cfg[:y_ticks] - idx).to_f / cfg[:y_ticks]
69
- tick_y = pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks])
39
+ def chart_tick(chart, index)
40
+ tick_y = chart.tick_y(index)
70
41
  y = chart_fmt(tick_y)
71
- label_y = chart_fmt(tick_y + 3)
72
- grid = %(<line class="lct-chart-grid" x1="#{left_x}" x2="#{right_x}" y1="#{y}" y2="#{y}"/>)
73
- text = %(<text class="lct-chart-axis" x="#{text_x}" y="#{label_y}" text-anchor="end">$#{chart_fmt(value)}</text>)
74
- "#{grid}#{text}"
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))}")
75
45
  end
76
46
 
77
- def chart_paths(cfg)
78
- line = build_line_path(cfg[:coords])
79
- base_y = cfg[:pad][:top] + cfg[:plot_h]
80
- area = build_area_path(cfg[:coords], cfg, base_y, line)
81
- secondary = if cfg[:comparison_coords].present?
82
- %(<path class="lct-chart-line-secondary" d="#{build_line_path(cfg[:comparison_coords])}"/>)
83
- else
84
- ""
85
- end
86
- %(<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
87
55
  end
88
56
 
89
- def build_line_path(coords)
90
- coords.each_with_index.map do |(x, y), idx|
91
- "#{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)}"
92
60
  end.join(" ")
93
61
  end
94
62
 
95
- def build_area_path(coords, cfg, base_y, line)
96
- if coords.size == 1
97
- x, y = coords.first
98
- left = chart_fmt(cfg[:pad][:left])
99
- right = chart_fmt(cfg[:pad][:left] + cfg[:plot_w])
100
- "M#{left},#{chart_fmt(base_y)} L#{chart_fmt(x)},#{chart_fmt(y)} L#{right},#{chart_fmt(base_y)} Z"
101
- else
102
- first_x = chart_fmt(coords.first[0])
103
- last_x = chart_fmt(coords.last[0])
104
- "#{line} L#{last_x},#{chart_fmt(base_y)} L#{first_x},#{chart_fmt(base_y)} Z"
105
- end
106
- 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
107
67
 
108
- def chart_dots(cfg)
109
- 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"
110
70
  end
111
71
 
112
- def chart_dot(cfg, pt_x, pt_y, idx)
113
- 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 }
114
77
  title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
115
- peak = idx == cfg[:peak_index]
116
- klass = peak ? "lct-chart-peak" : "lct-chart-dot"
117
- radius = peak ? 4 : 3
118
- circle = %(<circle class="#{klass}" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="#{radius}"/>)
119
- "<g>#{circle}<title>#{title}</title></g>"
78
+ "<g>#{chart_element(:circle, circle)}<title>#{title}</title></g>"
120
79
  end
121
80
 
122
- def chart_area_gradient_def
123
- "<defs><linearGradient id=\"lct-chart-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" \
124
- "<stop offset=\"0%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.28\"/>" \
125
- "<stop offset=\"100%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.02\"/>" \
126
- "</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]))
127
86
  end
128
87
 
129
- def chart_x_labels(cfg)
130
- indexes = cfg[:n] <= 2 ? (0...cfg[:n]).to_a : [0, cfg[:n] / 2, cfg[:n] - 1].uniq
131
- label_y = chart_fmt(cfg[:height] - 8)
132
- indexes.map { |idx| chart_x_label(cfg, idx, label_y) }
133
- end
88
+ def chart_label_anchor(chart, index)
89
+ return "start" if index.zero?
134
90
 
135
- def chart_x_label(cfg, idx, label_y)
136
- pt_x, = cfg[:coords][idx]
137
- label = ERB::Util.html_escape(cfg[:points][idx][:label])
138
- anchor = if idx.zero? then "start"
139
- elsif idx == cfg[:n] - 1 then "end"
140
- else "middle"
141
- end
142
- %(<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"
143
92
  end
144
93
  end
145
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
@@ -49,5 +49,35 @@ module LlmCostTracker
49
49
  call.line_items.map { |item| [item.kind, item.direction, item.cache_state, item.cost] }
50
50
  )
51
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))
65
+ end
66
+ end
67
+
68
+ def call_service_line_items(call)
69
+ call.line_items.reject { |item| item.unit == "token" }.sort_by(&:position)
70
+ end
71
+
72
+ private
73
+
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
+ }
81
+ end
52
82
  end
53
83
  end
@@ -69,8 +69,8 @@ module LlmCostTracker
69
69
 
70
70
  def cost_by_tag(key, limit: nil)
71
71
  cost = qualified(:total_cost)
72
- label = Ledger::Tags::Breakdown.label_sql(connection)
73
- raw_value = Ledger::Tags::Breakdown.raw_value_sql(connection)
72
+ label = Ledger::Tags::Breakdown.label_sql(lease_connection)
73
+ raw_value = Ledger::Tags::Breakdown.raw_value_sql(lease_connection)
74
74
  relation = Ledger::Tags::Breakdown.join_relation(self, key)
75
75
  .select("#{label} AS name", "COALESCE(SUM(#{cost}), 0) AS total_cost")
76
76
  .group(Arel.sql(label))
@@ -93,7 +93,9 @@ module LlmCostTracker
93
93
  column = column.to_s
94
94
  raise ArgumentError, "invalid period column: #{column.inspect}" unless column_names.include?(column)
95
95
 
96
- bucket = Ledger::Schema::Adapter.period_bucket_sql(connection, period, qualified(column), time_zone: time_zone)
96
+ bucket = Ledger::Schema::Adapter.period_bucket_sql(
97
+ lease_connection, period, qualified(column), time_zone: time_zone
98
+ )
97
99
  group(Arel.sql(bucket))
98
100
  end
99
101
 
@@ -104,7 +106,7 @@ module LlmCostTracker
104
106
  end
105
107
 
106
108
  def qualified(column)
107
- "#{quoted_table_name}.#{connection.quote_column_name(column)}"
109
+ "#{quoted_table_name}.#{lease_connection.quote_column_name(column)}"
108
110
  end
109
111
 
110
112
  private
@@ -34,15 +34,15 @@ module LlmCostTracker
34
34
  end
35
35
 
36
36
  def increment_on_duplicate
37
- return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(connection)
38
- return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(connection)
37
+ return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(lease_connection)
38
+ return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(lease_connection)
39
39
 
40
- Ledger::Schema::Adapter.ensure_supported!(connection)
40
+ Ledger::Schema::Adapter.ensure_supported!(lease_connection)
41
41
  end
42
42
 
43
43
  def postgres_increment_sql
44
- total = connection.quote_column_name("total_cost")
45
- updated = connection.quote_column_name("updated_at")
44
+ total = lease_connection.quote_column_name("total_cost")
45
+ updated = lease_connection.quote_column_name("updated_at")
46
46
  "#{total} = #{quoted_table_name}.#{total} + excluded.#{total}, #{updated} = excluded.#{updated}"
47
47
  end
48
48
 
@@ -51,7 +51,7 @@ module LlmCostTracker
51
51
  end
52
52
 
53
53
  def increment_unique_by
54
- return unless connection.supports_insert_conflict_target?
54
+ return unless lease_connection.supports_insert_conflict_target?
55
55
 
56
56
  %i[period period_start currency provider]
57
57
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ module CallOrder
6
+ NATURAL_DIRECTIONS = {
7
+ "tracked_at" => "desc",
8
+ "provider" => "asc",
9
+ "model" => "asc",
10
+ "input" => "desc",
11
+ "output" => "desc",
12
+ "cost" => "desc",
13
+ "latency" => "desc"
14
+ }.freeze
15
+ NEWEST_FIRST = { tracked_at: :desc, id: :desc }.freeze
16
+ COST_NULLS_LAST = Arel.sql("CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC")
17
+ LATENCY_NULLS_LAST = Arel.sql("CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC")
18
+ ORDERS = {
19
+ "tracked_at" => ->(dir) { [{ tracked_at: dir, id: dir }] },
20
+ "provider" => ->(dir) { [{ provider: dir, model: :asc, **NEWEST_FIRST }] },
21
+ "model" => ->(dir) { [{ model: dir, **NEWEST_FIRST }] },
22
+ "input" => ->(dir) { [{ input_tokens: dir, **NEWEST_FIRST }] },
23
+ "output" => ->(dir) { [{ output_tokens: dir, **NEWEST_FIRST }] },
24
+ "cost" => ->(dir) { [COST_NULLS_LAST, { total_cost: dir, **NEWEST_FIRST }] },
25
+ "latency" => ->(dir) { [LATENCY_NULLS_LAST, { latency_ms: dir, **NEWEST_FIRST }] }
26
+ }.freeze
27
+
28
+ def self.call(scope, sort:, direction:)
29
+ choice = Sort.resolve(
30
+ sort, direction.to_s.downcase, natural_directions: NATURAL_DIRECTIONS, fallback: "tracked_at"
31
+ )
32
+ scope.order(*ORDERS.fetch(choice.column).call(choice.direction.to_sym))
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+
6
+ module LlmCostTracker
7
+ module Dashboard
8
+ class CallsExport
9
+ MYSQL_HINT = "NO_SKIP_SCAN(llm_cost_tracker_calls)"
10
+ FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
11
+ MASKED_FIELDS = %i[provider_api_key_id provider_workspace_id provider_project_id].freeze
12
+ TEXT_FIELDS = %i[provider model provider_response_id cost_status].freeze
13
+ FIELDS = [
14
+ :tracked_at, :provider, :model, *Usage::TokenUsage.members, :total_cost, :cost_status, :pricing_snapshot,
15
+ :latency_ms, :provider_response_id, :provider_project_id, :provider_api_key_id, :provider_workspace_id,
16
+ :batch, :tags
17
+ ].freeze
18
+
19
+ def self.call(relation, limit:, batch_size:)
20
+ new(relation, limit: limit, batch_size: batch_size).to_s
21
+ end
22
+
23
+ def initialize(relation, limit:, batch_size:)
24
+ @relation = relation
25
+ @limit = limit
26
+ @batch_size = batch_size
27
+ end
28
+
29
+ def to_s
30
+ CSV.generate do |csv|
31
+ csv << FIELDS.map(&:to_s)
32
+ each_call { |call| csv << FIELDS.map { |field| value(field, call) } }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :relation, :limit, :batch_size
39
+
40
+ def each_call(&block)
41
+ scope = relation.limit(limit)
42
+ scope = scope.optimizer_hints(MYSQL_HINT) if Ledger::Schema::Adapter.mysql?(scope.lease_connection)
43
+ scope.pluck(:id).each_slice(batch_size) do |ids|
44
+ calls = LlmCostTracker::Call.where(id: ids).preload(:tag_records).index_by(&:id)
45
+ calls.values_at(*ids).compact.each(&block)
46
+ end
47
+ end
48
+
49
+ def value(field, call)
50
+ case field
51
+ when :tracked_at then call.tracked_at.utc.iso8601
52
+ when *MASKED_FIELDS then formula_safe(Masking.mask_value(field, call[field]))
53
+ when *TEXT_FIELDS then formula_safe(call[field])
54
+ when :pricing_snapshot then formula_safe(Hash(call.pricing_snapshot).deep_stringify_keys.to_json)
55
+ when :tags then formula_safe(call.tag_pairs.to_json)
56
+ else call[field]
57
+ end
58
+ end
59
+
60
+ def formula_safe(value)
61
+ return if value.nil?
62
+
63
+ string = value.to_s
64
+ FORMULA_PREFIXES.include?(string.lstrip[0]) ? "'#{string}" : string
65
+ end
66
+ end
67
+ end
68
+ end