llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,150 +1,131 @@
1
+ <% form_url = tag_path(params[:key]) %>
2
+
1
3
  <% if @value.present? %>
2
- <section class="lct-panel lct-toolbar">
3
- <div class="lct-toolbar-head">
4
- <div>
5
- <p class="lct-muted"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
6
- <h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code> = <code class="lct-code"><%= @value %></code></h2>
7
- </div>
8
- </div>
4
+ <p class="lct-breadcrumb-back"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
5
+ <h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code> = <code class="lct-code-id"><%= @value %></code></h2>
9
6
 
10
- <%= render "llm_cost_tracker/shared/filters",
11
- path: tag_path(params[:key]),
12
- fields: %i[from to provider model],
13
- hidden_fields: { tag_value: @value },
14
- reset_path: tag_path(params[:key], tag_value: @value) %>
15
- </section>
7
+ <div class="lct-filter-row">
8
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
9
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
10
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url, extra_hidden: { tag_value: @value }, extra_except: [:tag_value] %>
11
+
12
+ <% if params[:provider].present? || params[:model].present? %>
13
+ <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
14
+ <% end %>
15
+
16
+ <span class="lct-filter-row-meta"><%= number(@value_calls) %> call<%= "s" unless @value_calls == 1 %> · <%= money(@value_total_cost) %></span>
17
+ </div>
16
18
 
17
19
  <% if @value_calls.zero? %>
18
20
  <section class="lct-panel lct-empty">
19
21
  <h2 class="lct-state-title">No calls tagged with <%= params[:key] %>=<%= @value %></h2>
20
22
  <p class="lct-state-copy">No matching calls in the current slice.</p>
21
- <div class="lct-state-actions">
22
- <%= link_to "Back to values", tag_path(params[:key]), class: "lct-button lct-button-secondary" %>
23
- </div>
24
23
  </section>
25
24
  <% else %>
26
- <section class="lct-stat-grid lct-stat-grid-spaced">
27
- <article class="lct-stat">
28
- <p class="lct-stat-label">Total cost</p>
25
+ <div class="lct-stat-grid">
26
+ <div class="lct-stat">
27
+ <div class="lct-stat-head"><p class="lct-stat-label">Total cost</p></div>
29
28
  <p class="lct-stat-value"><%= money(@value_total_cost) %></p>
30
- <p class="lct-stat-copy">Across <%= number(@value_calls) %> calls</p>
31
- </article>
32
-
33
- <article class="lct-stat">
34
- <p class="lct-stat-label">Calls</p>
29
+ <p class="lct-stat-foot">Across <%= number(@value_calls) %> calls</p>
30
+ </div>
31
+ <div class="lct-stat">
32
+ <div class="lct-stat-head"><p class="lct-stat-label">Calls</p></div>
35
33
  <p class="lct-stat-value"><%= number(@value_calls) %></p>
36
- <p class="lct-stat-copy">Tagged with <code class="lct-code"><%= @value %></code></p>
37
- </article>
38
-
39
- <article class="lct-stat">
40
- <p class="lct-stat-label">Avg cost / call</p>
34
+ <p class="lct-stat-foot">Tagged with <code class="lct-code-id"><%= @value %></code></p>
35
+ </div>
36
+ <div class="lct-stat">
37
+ <div class="lct-stat-head"><p class="lct-stat-label">Avg cost / call</p></div>
41
38
  <p class="lct-stat-value"><%= money(@value_calls.positive? ? @value_total_cost / @value_calls : 0) %></p>
42
- <p class="lct-stat-copy">Mean over the slice</p>
43
- </article>
44
- </section>
39
+ </div>
40
+ </div>
45
41
 
46
42
  <section class="lct-panel">
47
- <div class="lct-section-head">
48
- <div>
49
- <h2 class="lct-section-title">Spend over time</h2>
50
- <p class="lct-section-copy">Daily total cost for calls tagged <code class="lct-code"><%= params[:key] %>=<%= @value %></code>.</p>
51
- </div>
52
- <%= link_to "Calls", calls_path(calls_query_for_tag(key: @key, value: @value)), class: "lct-button lct-button-secondary lct-button-compact" %>
43
+ <div class="lct-panel-head">
44
+ <h2 class="lct-panel-title">Spend over time</h2>
45
+ <span class="lct-panel-meta"><%= link_to "View calls →", calls_path(calls_query_for_tag(key: @key, value: @value)) %></span>
53
46
  </div>
54
-
55
- <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
47
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points, comparison_series: [] %>
56
48
  </section>
57
49
  <% end %>
50
+
58
51
  <% else %>
59
- <section class="lct-panel lct-toolbar">
60
- <div class="lct-toolbar-head">
61
- <div>
62
- <p class="lct-muted"><%= link_to "← All tag keys", tags_path(current_query) %></p>
63
- <h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code></h2>
64
- </div>
65
- </div>
66
52
 
67
- <%= render "llm_cost_tracker/shared/filters",
68
- path: tag_path(params[:key]),
69
- fields: %i[from to provider model],
70
- reset_path: tag_path(params[:key]) %>
53
+ <p class="lct-breadcrumb-back"><%= link_to "← All tag keys", tags_path(current_query) %></p>
54
+ <h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code></h2>
71
55
 
72
- <%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tag_path(params[:key]) %>
56
+ <div class="lct-filter-row">
57
+ <%= render "llm_cost_tracker/shared/filter_pill_date", path: form_url %>
58
+ <%= render "llm_cost_tracker/shared/filter_pill_provider", path: form_url %>
59
+ <%= render "llm_cost_tracker/shared/filter_pill_model", path: form_url %>
73
60
 
74
- <p class="lct-summary-row">
75
- <span><strong><%= number(@breakdown.tagged_calls) %></strong> tagged calls</span>
76
- <span><strong><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></strong> coverage</span>
77
- <span><strong><%= number(@breakdown.distinct_values) %></strong> distinct values</span>
78
- </p>
61
+ <% if params[:provider].present? || params[:model].present? %>
62
+ <%= link_to "× Clear filters", tag_path(params[:key], current_query(provider: nil, model: nil, page: nil)), class: "lct-filter-clear" %>
63
+ <% end %>
79
64
 
80
- <% if @breakdown.distinct_values > @breakdown.rows.size %>
81
- <p class="lct-toolbar-note">Showing top <%= number(@breakdown.limit) %> values by spend.</p>
82
- <% end %>
83
- </section>
65
+ <span class="lct-filter-row-meta"><%= number(@breakdown.tagged_calls) %> tagged call<%= "s" unless @breakdown.tagged_calls == 1 %> · <%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %> coverage · <%= number(@breakdown.distinct_values) %> distinct value<%= "s" unless @breakdown.distinct_values == 1 %></span>
66
+ </div>
84
67
 
85
- <% if @breakdown.rows.empty? %>
86
- <section class="lct-panel lct-empty">
87
- <h2 class="lct-state-title">No calls tagged with <%= params[:key] %></h2>
88
- <p class="lct-state-copy">Values for this key will appear here once matching calls carry the tag in the current slice.</p>
89
- <div class="lct-state-actions">
90
- <%= link_to "Clear filters", tag_path(params[:key]), class: "lct-button lct-button-secondary" %>
68
+ <% if @breakdown.rows.empty? %>
69
+ <section class="lct-panel lct-empty">
70
+ <h2 class="lct-state-title">No calls tagged with <%= params[:key] %></h2>
71
+ <p class="lct-state-copy">Values for this key will appear here once matching calls carry the tag in the current slice.</p>
72
+ </section>
73
+ <% else %>
74
+ <div class="lct-stat-grid">
75
+ <div class="lct-stat">
76
+ <div class="lct-stat-head"><p class="lct-stat-label">Tagged calls</p></div>
77
+ <p class="lct-stat-value"><%= number(@breakdown.tagged_calls) %></p>
78
+ <p class="lct-stat-foot">Rows that include <code class="lct-code-id"><%= params[:key] %></code></p>
79
+ </div>
80
+ <div class="lct-stat">
81
+ <div class="lct-stat-head"><p class="lct-stat-label">Coverage</p></div>
82
+ <p class="lct-stat-value"><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></p>
83
+ <p class="lct-stat-foot"><%= number(@breakdown.total_calls) %> total calls in this slice</p>
84
+ </div>
85
+ <div class="lct-stat">
86
+ <div class="lct-stat-head"><p class="lct-stat-label">Distinct values</p></div>
87
+ <p class="lct-stat-value"><%= number(@breakdown.distinct_values) %></p>
88
+ </div>
91
89
  </div>
92
- </section>
93
- <% else %>
94
- <section class="lct-stat-grid lct-stat-grid-spaced">
95
- <article class="lct-stat">
96
- <p class="lct-stat-label">Tagged calls</p>
97
- <p class="lct-stat-value"><%= number(@breakdown.tagged_calls) %></p>
98
- <p class="lct-stat-copy">Rows that include <code class="lct-code"><%= params[:key] %></code></p>
99
- </article>
100
-
101
- <article class="lct-stat">
102
- <p class="lct-stat-label">Coverage</p>
103
- <p class="lct-stat-value"><%= percent(coverage_percent(@breakdown.tagged_calls, @breakdown.total_calls)) %></p>
104
- <p class="lct-stat-copy"><%= number(@breakdown.total_calls) %> total calls in this slice</p>
105
- </article>
106
90
 
107
- <article class="lct-stat">
108
- <p class="lct-stat-label">Distinct values</p>
109
- <p class="lct-stat-value"><%= number(@breakdown.distinct_values) %></p>
110
- <p class="lct-stat-copy">Unique values currently visible</p>
111
- </article>
112
- </section>
91
+ <% if @breakdown.distinct_values > @breakdown.rows.size %>
92
+ <div class="lct-alert lct-alert-info">
93
+ <span>Showing top <%= number(@breakdown.limit) %> values by spend.</span>
94
+ </div>
95
+ <% end %>
113
96
 
114
- <section class="lct-panel">
115
- <div class="lct-table-wrap">
116
- <table class="lct-table lct-table-compact">
97
+ <section class="lct-panel">
98
+ <table class="lct-tbl">
117
99
  <thead>
118
100
  <tr>
119
- <th>Value</th>
120
- <th class="lct-num">Calls</th>
101
+ <%= sortable_header("Value", "value") %>
102
+ <%= sortable_header("Calls", "calls", num: true) %>
121
103
  <th class="lct-num">Share</th>
122
- <th class="lct-num">Total cost</th>
123
- <th class="lct-num">Avg cost / call</th>
104
+ <%= sortable_header("Total cost", "cost", num: true) %>
105
+ <%= sortable_header("Avg cost / call", "avg_cost", num: true) %>
124
106
  <th></th>
125
107
  </tr>
126
108
  </thead>
127
109
  <tbody>
128
110
  <% @breakdown.rows.each do |row| %>
129
111
  <tr>
130
- <td><code class="lct-code"><%= row.value %></code></td>
112
+ <td><code class="lct-code-id"><%= row.value %></code></td>
131
113
  <td class="lct-num"><%= number(row.calls) %></td>
132
114
  <td class="lct-num"><%= percent(row.share_percent) %></td>
133
115
  <td class="lct-num"><%= money(row.total_cost) %></td>
134
116
  <td class="lct-num"><%= money(row.average_cost_per_call) %></td>
135
- <td>
117
+ <td class="lct-num">
136
118
  <% if row.value == "(untagged)" %>
137
- <span class="lct-muted">n/a</span>
119
+ <span class="lct-num-muted">n/a</span>
138
120
  <% else %>
139
- <%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
140
- <%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
121
+ <%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-page-link" %>
122
+ <%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-page-link" %>
141
123
  <% end %>
142
124
  </td>
143
125
  </tr>
144
126
  <% end %>
145
127
  </tbody>
146
128
  </table>
147
- </div>
148
- </section>
149
- <% end %>
129
+ </section>
130
+ <% end %>
150
131
  <% end %>
data/config/routes.rb CHANGED
@@ -6,6 +6,7 @@ LlmCostTracker::Engine.routes.draw do
6
6
  resources :models, only: :index
7
7
  resources :tags, only: %i[index show], param: :key, format: false
8
8
  get "data_quality", to: "data_quality#index", as: :data_quality
9
+ get "pricing", to: "pricing#index", as: :pricing
9
10
  get "reconciliation", to: "reconciliation#index", as: :reconciliation
10
11
  post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
11
12
 
@@ -10,36 +10,32 @@ module LlmCostTracker
10
10
  PARTIAL = "partial"
11
11
  UNKNOWN = "unknown"
12
12
 
13
- class << self
14
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
15
- def call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
16
- token_pricing_partial: false)
17
- return UNKNOWN if usage_source == :unknown
13
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
14
+ def self.call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
15
+ token_pricing_partial: false)
16
+ return UNKNOWN if usage_source == :unknown
18
17
 
19
- token_billable = Components::TOKEN_PRICED.any? do |component|
20
- token_usage.public_send(component.token_key).positive?
21
- end
22
- service_billable = false
23
- service_priced = false
24
- service_unpriced = false
25
- service_line_items.each do |line_item|
26
- next unless line_item.billable?
18
+ token_billable = token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
19
+ service_billable = false
20
+ service_priced = false
21
+ service_unpriced = false
22
+ service_line_items.each do |line_item|
23
+ next unless line_item.billable?
27
24
 
28
- service_billable = true
29
- service_priced ||= line_item.priced?
30
- service_unpriced ||= line_item.unpriced?
31
- break if service_priced && service_unpriced
32
- end
25
+ service_billable = true
26
+ service_priced ||= line_item.priced?
27
+ service_unpriced ||= line_item.unpriced?
28
+ break if service_priced && service_unpriced
29
+ end
33
30
 
34
- priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
35
- unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
36
- return UNKNOWN if unpriced && !priced
37
- return PARTIAL if unpriced
31
+ priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
32
+ unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
33
+ return UNKNOWN if unpriced && !priced
34
+ return PARTIAL if unpriced
38
35
 
39
- total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
40
- end
41
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
36
+ total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
42
37
  end
38
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
43
39
  end
44
40
  end
45
41
  end
@@ -30,28 +30,11 @@ module LlmCostTracker
30
30
 
31
31
  class LineItem
32
32
  USD = "USD"
33
- OPTIONAL_ATTRIBUTES = %i[
34
- pricing_basis
35
- price_key
36
- price_source
37
- price_source_version
38
- provider_field
39
- provider_item_id
40
- ].freeze
41
- SYMBOL_ATTRIBUTES = %i[
42
- kind
43
- direction
44
- modality
45
- cache_state
46
- unit
47
- pricing_basis
48
- price_source
49
- ].freeze
50
33
 
51
34
  def self.build(attributes)
52
35
  attributes = attributes.to_h
53
36
  component = component_for(attributes)
54
- normalized = {
37
+ new(
55
38
  kind: symbol_or_nil(attributes[:kind]) || component&.kind,
56
39
  direction: symbol_or_nil(attributes[:direction]) || component&.direction,
57
40
  modality: symbol_or_nil(attributes[:modality]) || component&.modality,
@@ -63,38 +46,30 @@ module LlmCostTracker
63
46
  cost: decimal_or_nil(attributes[:cost]),
64
47
  currency: attributes[:currency] || USD,
65
48
  cost_status: cost_status_for(attributes),
49
+ pricing_basis: symbol_or_nil(attributes[:pricing_basis]),
50
+ price_key: attributes[:price_key],
51
+ price_source: symbol_or_nil(attributes[:price_source]),
52
+ price_source_version: attributes[:price_source_version],
53
+ provider_field: attributes[:provider_field],
54
+ provider_item_id: attributes[:provider_item_id],
66
55
  details: attributes[:details] || {}
67
- }.merge(optional_attributes_for(attributes))
68
-
69
- new(**normalized)
56
+ )
70
57
  end
71
58
 
72
59
  def self.from_token_usage(token_usage)
73
60
  return [] unless token_usage
74
61
 
75
- Components::TOKEN_PRICED.filter_map do |component|
76
- quantity = token_usage.public_send(component.token_key)
62
+ token_usage.priced_quantities.filter_map do |key, quantity|
77
63
  next unless quantity.positive?
78
64
 
79
- new(
65
+ component = Components::BY_KEY.fetch(key)
66
+ build(
80
67
  kind: component.kind,
81
68
  direction: component.direction,
82
69
  modality: component.modality,
83
70
  cache_state: component.cache_state,
84
- quantity: BigDecimal(quantity.to_s),
85
- unit: component.unit,
86
- rate_amount: nil,
87
- rate_quantity: BigDecimal("1"),
88
- cost: nil,
89
- currency: USD,
90
- cost_status: CostStatus::UNKNOWN,
91
- pricing_basis: nil,
92
- price_key: nil,
93
- price_source: nil,
94
- price_source_version: nil,
95
- provider_field: nil,
96
- provider_item_id: nil,
97
- details: {}
71
+ quantity: quantity,
72
+ unit: component.unit
98
73
  )
99
74
  end
100
75
  end
@@ -132,16 +107,7 @@ module LlmCostTracker
132
107
  decimal_or_nil(value) || BigDecimal("0")
133
108
  end
134
109
 
135
- def self.optional_attributes_for(attributes)
136
- OPTIONAL_ATTRIBUTES.to_h do |key|
137
- value = attributes[key]
138
- value = value.to_sym if value.is_a?(String) && SYMBOL_ATTRIBUTES.include?(key)
139
- [key, value]
140
- end
141
- end
142
-
143
- private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero,
144
- :optional_attributes_for
110
+ private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero
145
111
 
146
112
  def billable?
147
113
  quantity.positive?
@@ -163,7 +129,7 @@ module LlmCostTracker
163
129
  cost || BigDecimal("0")
164
130
  end
165
131
 
166
- def apply_rate(rate)
132
+ def with_rate(rate)
167
133
  rate_amount = rate.fetch(:amount)
168
134
  rate_quantity = rate.fetch(:quantity)
169
135
  applied_cost = (quantity / rate_quantity) * rate_amount
@@ -1,28 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+
3
5
  require_relative "logging"
4
6
  require_relative "ledger"
7
+ require_relative "pricing/estimator"
5
8
 
6
9
  module LlmCostTracker
7
10
  class Budget
8
11
  BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
9
12
 
10
13
  class << self
11
- def enforce!
14
+ def enforce!(provider: nil, model: nil, request: nil)
12
15
  config = LlmCostTracker.configuration
13
16
  return unless config.budget_exceeded_behavior == :block_requests
14
17
 
18
+ estimate = estimate_cost(provider: provider, model: model, request: request)
19
+ raise_per_call_pre_send(estimate, config.per_call_budget) if config.per_call_budget && estimate.positive?
20
+
15
21
  budgets = { monthly: config.monthly_budget, daily: config.daily_budget }.compact
16
22
  return if budgets.empty?
17
23
 
18
24
  totals = totals_for(budgets.keys, time: Time.now.utc)
19
25
 
20
26
  budgets.each do |budget_type, budget|
21
- total = totals.fetch(budget_type)
27
+ total = totals.fetch(budget_type) + estimate
22
28
  next unless total >= budget
23
29
 
24
30
  raise BudgetExceededError.new(**budget_payload(
25
- budget_type: budget_type, total: total, budget: budget, last_event: nil
31
+ budget_type: budget_type, total: total, budget: budget, last_event: nil, stage: :pre_send
26
32
  ))
27
33
  end
28
34
  end
@@ -44,6 +50,20 @@ module LlmCostTracker
44
50
 
45
51
  private
46
52
 
53
+ def estimate_cost(provider:, model:, request:)
54
+ return BigDecimal("0") unless provider && model && request
55
+
56
+ Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
57
+ end
58
+
59
+ def raise_per_call_pre_send(estimate, budget)
60
+ return unless estimate >= budget
61
+
62
+ raise BudgetExceededError.new(**budget_payload(
63
+ budget_type: :per_call, total: estimate, budget: budget, last_event: nil, stage: :pre_send
64
+ ))
65
+ end
66
+
47
67
  def check_per_call_budget(event, config)
48
68
  budget = config.per_call_budget
49
69
  return unless budget
@@ -70,7 +90,8 @@ module LlmCostTracker
70
90
  budget_type: budget_type,
71
91
  total: total,
72
92
  budget: budget,
73
- last_event: last_event
93
+ last_event: last_event,
94
+ stage: :post_spend
74
95
  )
75
96
 
76
97
  if notify_exceeded?(config, budget_type: budget_type, total: total, budget: budget, last_event: last_event)
@@ -79,19 +100,19 @@ module LlmCostTracker
79
100
  raise BudgetExceededError.new(**payload) if %i[raise block_requests].include?(config.budget_exceeded_behavior)
80
101
  end
81
102
 
82
- def budget_payload(budget_type:, total:, budget:, last_event:)
103
+ def budget_payload(budget_type:, total:, budget:, last_event:, stage:)
83
104
  {
84
105
  budget_type: budget_type,
85
106
  total: total,
86
107
  budget: budget,
87
- last_event: last_event
108
+ last_event: last_event,
109
+ stage: stage
88
110
  }
89
111
  end
90
112
 
91
113
  def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
92
114
  return false unless config.on_budget_exceeded
93
- return true unless last_event&.total_cost
94
- return true if budget_type == :per_call
115
+ return true if !last_event&.total_cost || budget_type == :per_call
95
116
 
96
117
  total - last_event.total_cost < budget
97
118
  end
@@ -4,7 +4,7 @@ require "active_support/core_ext/object/blank"
4
4
  require "json"
5
5
 
6
6
  module LlmCostTracker
7
- module Parsers
7
+ module Capture
8
8
  module SSE
9
9
  DONE_MARKER = "[DONE]"
10
10