llm_cost_tracker 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +12 -5
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  26. data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
  27. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  28. data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  31. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  32. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  34. data/config/routes.rb +3 -2
  35. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  36. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  37. data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
  38. data/lib/llm_cost_tracker/budget.rb +4 -2
  39. data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
  40. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  41. data/lib/llm_cost_tracker/configuration.rb +53 -1
  42. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  43. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  44. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
  45. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  46. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  47. data/lib/llm_cost_tracker/doctor.rb +72 -3
  48. data/lib/llm_cost_tracker/engine.rb +9 -0
  49. data/lib/llm_cost_tracker/event.rb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
  66. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
  68. data/lib/llm_cost_tracker/ingestion.rb +48 -10
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
  70. data/lib/llm_cost_tracker/integrations/base.rb +22 -5
  71. data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
  73. data/lib/llm_cost_tracker/integrations.rb +19 -1
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
  75. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
  76. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
  77. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
  78. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
  79. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
  80. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  81. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  82. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  83. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
  84. data/lib/llm_cost_tracker/ledger/store.rb +14 -14
  85. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
  87. data/lib/llm_cost_tracker/ledger.rb +2 -1
  88. data/lib/llm_cost_tracker/masking.rb +39 -0
  89. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
  90. data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
  91. data/lib/llm_cost_tracker/parsers/base.rb +5 -1
  92. data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
  93. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
  97. data/lib/llm_cost_tracker/prices.json +110 -19
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
  99. data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
  100. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  101. data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
  102. data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  104. data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
  105. data/lib/llm_cost_tracker/pricing.rb +47 -19
  106. data/lib/llm_cost_tracker/railtie.rb +6 -0
  107. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  108. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  109. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  110. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  111. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  112. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  113. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  114. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  115. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  116. data/lib/llm_cost_tracker/report/data.rb +4 -1
  117. data/lib/llm_cost_tracker/retention.rb +15 -2
  118. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  119. data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
  120. data/lib/llm_cost_tracker/token_usage.rb +10 -2
  121. data/lib/llm_cost_tracker/tracker.rb +45 -18
  122. data/lib/llm_cost_tracker/version.rb +1 -1
  123. data/lib/llm_cost_tracker.rb +9 -0
  124. data/lib/tasks/llm_cost_tracker.rake +25 -2
  125. metadata +36 -1
@@ -0,0 +1,183 @@
1
+ <section class="lct-panel">
2
+ <div class="lct-toolbar-head">
3
+ <h2 class="lct-section-title">Provider Invoice Reconciliation <span class="lct-badge lct-badge-warn">Experimental</span></h2>
4
+ <% if @last_imported_at %>
5
+ <p class="lct-state-copy">Last import: <%= @last_imported_at.utc.iso8601 %></p>
6
+ <% end %>
7
+ </div>
8
+ <p class="lct-state-copy">
9
+ Experimental in v0.9.0 — public API may change in v0.9.x based on feedback.
10
+ <%= link_to "Open an issue", "https://github.com/sergey-homenko/llm_cost_tracker/issues", target: "_blank", rel: "noopener" %>
11
+ if you use it.
12
+ </p>
13
+ </section>
14
+
15
+ <% if flash[:notice] %>
16
+ <section class="lct-panel"><p class="lct-state-copy"><%= flash[:notice] %></p></section>
17
+ <% end %>
18
+ <% if flash[:alert] %>
19
+ <section class="lct-panel"><p class="lct-state-copy"><%= flash[:alert] %></p></section>
20
+ <% end %>
21
+
22
+ <% if @configured_importers.any? %>
23
+ <section class="lct-panel">
24
+ <h3 class="lct-section-title">Trigger import</h3>
25
+ <% @configured_importers.each_key do |source| %>
26
+ <%= button_to "Re-import #{source}",
27
+ reconciliation_import_path(source: source),
28
+ method: :post,
29
+ class: "lct-button lct-button-secondary" %>
30
+ <% end %>
31
+ </section>
32
+ <% end %>
33
+
34
+ <% if !@reconciliation_enabled %>
35
+ <section class="lct-panel lct-empty">
36
+ <h2 class="lct-state-title">Reconciliation disabled</h2>
37
+ <p class="lct-state-copy">
38
+ Provider invoice reconciliation is opt-in because it requires admin/org-level
39
+ provider API keys (OpenAI <code>sk-admin-…</code>, Anthropic admin keys, GCP
40
+ <code>billing.viewer</code>) — separate from the runtime inference key the
41
+ tracker uses. Enable explicitly in the initializer:
42
+ </p>
43
+ <pre class="lct-state-pre"><code>LlmCostTracker.configure do |config|
44
+ config.reconciliation_enabled = true
45
+ end</code></pre>
46
+ </section>
47
+ <% elsif !@reconciliation_installed %>
48
+ <section class="lct-panel lct-empty">
49
+ <h2 class="lct-state-title">Reconciliation not installed</h2>
50
+ <p class="lct-state-copy">
51
+ Run the optional migration to create the reconciliation tables:
52
+ </p>
53
+ <pre class="lct-state-pre"><code>bin/rails generate llm_cost_tracker:reconciliation
54
+ bin/rails db:migrate</code></pre>
55
+ </section>
56
+ <% elsif @diffs.empty? %>
57
+ <section class="lct-panel lct-empty">
58
+ <h2 class="lct-state-title">No invoices imported yet</h2>
59
+ <p class="lct-state-copy">
60
+ Reconciliation compares provider-side invoices against local cost. Once you import
61
+ invoice rows via <code>LlmCostTracker::Reconciliation.import</code>, they appear here.
62
+ </p>
63
+ </section>
64
+ <% else %>
65
+ <section class="lct-panel">
66
+ <h3 class="lct-section-title">Latest period per source / provider / currency</h3>
67
+ <table class="lct-table">
68
+ <thead>
69
+ <tr>
70
+ <th>Source</th>
71
+ <th>Provider</th>
72
+ <th>Currency</th>
73
+ <th>Period</th>
74
+ <th class="lct-num">Provider total</th>
75
+ <th class="lct-num">Local total</th>
76
+ <th class="lct-num">Delta</th>
77
+ <th class="lct-num">%</th>
78
+ <th>Status</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ <% @diffs.each do |diff| %>
83
+ <tr>
84
+ <td><%= diff.source %></td>
85
+ <td><%= diff.provider %></td>
86
+ <td><%= diff.currency %></td>
87
+ <td><%= diff.period_start %> → <%= diff.period_end %></td>
88
+ <td class="lct-num"><%= money(diff.provider_total) %></td>
89
+ <td class="lct-num"><%= money(diff.local_total) %></td>
90
+ <td class="lct-num"><%= money(diff.delta_amount) %></td>
91
+ <td class="lct-num"><%= diff.delta_percent.nil? ? "—" : "#{diff.delta_percent}%" %></td>
92
+ <td>
93
+ <% if diff.aligned?(threshold_percent: @threshold) %>
94
+ <span class="lct-badge lct-badge-ok">Aligned</span>
95
+ <% else %>
96
+ <span class="lct-badge lct-badge-warn">Drift</span>
97
+ <% end %>
98
+ </td>
99
+ </tr>
100
+ <% end %>
101
+ </tbody>
102
+ </table>
103
+ </section>
104
+
105
+ <% @diffs.each do |diff| %>
106
+ <% next if diff.unmatched_provider_rows.empty? && diff.unmatched_local_calls.empty? && diff.non_cost_rows.empty? %>
107
+
108
+ <section class="lct-panel">
109
+ <h3 class="lct-section-title"><%= diff.source %> / <%= diff.provider %> / <%= diff.currency %> — drill down</h3>
110
+
111
+ <% if diff.unmatched_provider_rows.any? %>
112
+ <h4 class="lct-state-title">
113
+ Provider rows without a matching local call
114
+ <% if diff.unmatched_provider_rows_truncated? %>
115
+ <small>(showing <%= diff.unmatched_provider_rows.size %> of <%= diff.unmatched_provider_rows_total %>, ranked by billed amount)</small>
116
+ <% end %>
117
+ </h4>
118
+ <table class="lct-table">
119
+ <thead>
120
+ <tr><th>External ID</th><th>Match basis</th><th>Attribution</th><th class="lct-num">Billed</th></tr>
121
+ </thead>
122
+ <tbody>
123
+ <% diff.unmatched_provider_rows.each do |row| %>
124
+ <tr>
125
+ <td><%= row[:external_id] %></td>
126
+ <td><%= row[:match_basis] %></td>
127
+ <td><%= attribution_summary(row[:attribution]) %></td>
128
+ <td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
129
+ </tr>
130
+ <% end %>
131
+ </tbody>
132
+ </table>
133
+ <% end %>
134
+
135
+ <% if diff.unmatched_local_calls.any? %>
136
+ <h4 class="lct-state-title">
137
+ Local calls no provider invoice can explain
138
+ <% if diff.unmatched_local_calls_truncated? %>
139
+ <small>(showing <%= diff.unmatched_local_calls.size %> of <%= diff.unmatched_local_calls_total %>, ranked by total cost)</small>
140
+ <% end %>
141
+ </h4>
142
+ <table class="lct-table">
143
+ <thead>
144
+ <tr><th>Attribution</th><th class="lct-num">Calls</th><th class="lct-num">Total cost</th></tr>
145
+ </thead>
146
+ <tbody>
147
+ <% diff.unmatched_local_calls.each do |row| %>
148
+ <tr>
149
+ <td><%= attribution_summary(row[:attribution]) %></td>
150
+ <td class="lct-num"><%= number(row[:count]) %></td>
151
+ <td class="lct-num"><%= money(row[:total_cost]) %></td>
152
+ </tr>
153
+ <% end %>
154
+ </tbody>
155
+ </table>
156
+ <% end %>
157
+
158
+ <% if diff.non_cost_rows.any? %>
159
+ <h4 class="lct-state-title">
160
+ Non-cost evidence (free quota, credits, adjustments)
161
+ <% if diff.non_cost_rows_truncated? %>
162
+ <small>(showing <%= diff.non_cost_rows.size %> of <%= diff.non_cost_rows_total %>, ranked by amount)</small>
163
+ <% end %>
164
+ </h4>
165
+ <table class="lct-table">
166
+ <thead>
167
+ <tr><th>Row type</th><th>Meter</th><th>Attribution</th><th class="lct-num">Amount</th></tr>
168
+ </thead>
169
+ <tbody>
170
+ <% diff.non_cost_rows.each do |row| %>
171
+ <tr>
172
+ <td><%= row[:row_type] %></td>
173
+ <td><%= row[:meter] %></td>
174
+ <td><%= attribution_summary(row[:attribution]) %></td>
175
+ <td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
176
+ </tr>
177
+ <% end %>
178
+ </tbody>
179
+ </table>
180
+ <% end %>
181
+ </section>
182
+ <% end %>
183
+ <% end %>
@@ -1,5 +1,5 @@
1
1
  <% variant_class = local_assigns[:variant] == "budget" ? " lct-budget-fill" : "" %>
2
2
 
3
3
  <div class="lct-bar-track" aria-hidden="true">
4
- <div class="lct-bar-fill<%= variant_class %>" style="width: <%= bar_width(value, max) %>"></div>
4
+ <div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill<%= variant_class %>"></div>
5
5
  </div>
@@ -10,6 +10,9 @@
10
10
  %>
11
11
 
12
12
  <form class="lct-filters" action="<%= path %>" method="get">
13
+ <% local_assigns.fetch(:hidden_fields, {}).each do |key, val| %>
14
+ <%= hidden_field_tag(key, val) %>
15
+ <% end %>
13
16
  <div class="lct-filter-row">
14
17
  <% if fields.include?(:from) %>
15
18
  <div class="lct-field">
@@ -5,7 +5,7 @@
5
5
  <% else %>
6
6
  <div class="lct-stack-track" aria-hidden="true">
7
7
  <% visible_segments.each do |segment| %>
8
- <span class="lct-stack-fill <%= segment[:css_class] %>" style="width: <%= segment[:percent].round(2) %>%"></span>
8
+ <span data-lct-style="<%= inline_style("width: #{segment[:percent].round(2)}%") %>" class="lct-stack-fill <%= segment[:css_class] %>"></span>
9
9
  <% end %>
10
10
  </div>
11
11
 
@@ -1,3 +1,61 @@
1
+ <% 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>
9
+
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>
16
+
17
+ <% if @value_calls.zero? %>
18
+ <section class="lct-panel lct-empty">
19
+ <h2 class="lct-state-title">No calls tagged with <%= params[:key] %>=<%= @value %></h2>
20
+ <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
+ </section>
25
+ <% 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>
29
+ <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>
35
+ <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>
41
+ <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>
45
+
46
+ <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" %>
53
+ </div>
54
+
55
+ <%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
56
+ </section>
57
+ <% end %>
58
+ <% else %>
1
59
  <section class="lct-panel lct-toolbar">
2
60
  <div class="lct-toolbar-head">
3
61
  <div>
@@ -78,6 +136,7 @@
78
136
  <% if row.value == "(untagged)" %>
79
137
  <span class="lct-muted">n/a</span>
80
138
  <% 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" %>
81
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" %>
82
141
  <% end %>
83
142
  </td>
@@ -88,3 +147,4 @@
88
147
  </div>
89
148
  </section>
90
149
  <% end %>
150
+ <% end %>
data/config/routes.rb CHANGED
@@ -4,9 +4,10 @@ LlmCostTracker::Engine.routes.draw do
4
4
  root "dashboard#index"
5
5
  resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
6
6
  resources :models, only: :index
7
- get "tags", to: "tags#index", as: :tags
8
- get "tags/:key", to: "tags#show", as: :tag, format: false
7
+ resources :tags, only: %i[index show], param: :key, format: false
9
8
  get "data_quality", to: "data_quality#index", as: :data_quality
9
+ get "reconciliation", to: "reconciliation#index", as: :reconciliation
10
+ post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
10
11
 
11
12
  get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
12
13
  to: "assets#stylesheet", as: :stylesheet
@@ -6,6 +6,37 @@ require_relative "../errors"
6
6
 
7
7
  module LlmCostTracker
8
8
  module Billing
9
+ RATE_BASES = %i[
10
+ per_million_tokens
11
+ per_million_characters
12
+ per_request
13
+ per_1k_requests
14
+ per_session
15
+ per_hour
16
+ per_gb_day
17
+ per_image
18
+ ].freeze
19
+
20
+ RATE_BASIS_QUANTITIES = {
21
+ per_million_tokens: 1_000_000,
22
+ per_million_characters: 1_000_000,
23
+ per_request: 1,
24
+ per_1k_requests: 1_000,
25
+ per_session: 1,
26
+ per_hour: 1,
27
+ per_gb_day: 1,
28
+ per_image: 1
29
+ }.freeze
30
+
31
+ DEFAULT_RATE_BASIS_BY_UNIT = {
32
+ token: :per_million_tokens,
33
+ character: :per_million_characters,
34
+ request: :per_request,
35
+ session: :per_session,
36
+ hour: :per_hour,
37
+ image: :per_image
38
+ }.freeze
39
+
9
40
  module Components
10
41
  Component = Data.define(
11
42
  :key,
@@ -16,7 +47,8 @@ module LlmCostTracker
16
47
  :unit,
17
48
  :category,
18
49
  :token_key,
19
- :cost_key
50
+ :cost_key,
51
+ :rate_basis
20
52
  )
21
53
 
22
54
  REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
@@ -32,16 +64,26 @@ module LlmCostTracker
32
64
  missing = REQUIRED_FIELDS - attributes.keys
33
65
  raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
34
66
 
67
+ unit = attributes.fetch(:unit).to_sym
68
+ rate_basis = attributes[:rate_basis]&.to_sym || Billing::DEFAULT_RATE_BASIS_BY_UNIT[unit]
69
+ if rate_basis.nil?
70
+ raise Error, "components.yml entry needs rate_basis for unit #{unit.inspect}: #{attributes.inspect}"
71
+ end
72
+ unless Billing::RATE_BASES.include?(rate_basis)
73
+ raise Error, "components.yml entry has unknown rate_basis #{rate_basis.inspect}: #{attributes.inspect}"
74
+ end
75
+
35
76
  Component.new(
36
77
  key: attributes.fetch(:key).to_sym,
37
78
  kind: attributes.fetch(:kind).to_sym,
38
79
  direction: attributes.fetch(:direction).to_sym,
39
80
  modality: attributes.fetch(:modality).to_sym,
40
81
  cache_state: attributes.fetch(:cache_state).to_sym,
41
- unit: attributes.fetch(:unit).to_sym,
82
+ unit: unit,
42
83
  category: attributes.fetch(:category).to_sym,
43
84
  token_key: attributes[:token_key]&.to_sym,
44
- cost_key: attributes[:cost_key]&.to_sym
85
+ cost_key: attributes[:cost_key]&.to_sym,
86
+ rate_basis: rate_basis
45
87
  )
46
88
  end
47
89
 
@@ -68,6 +68,26 @@
68
68
  token_key: audio_output_tokens
69
69
  cost_key: audio_output_cost
70
70
 
71
+ - key: image_input
72
+ kind: image_token
73
+ direction: input
74
+ modality: image
75
+ cache_state: none
76
+ unit: token
77
+ category: token
78
+ token_key: image_input_tokens
79
+ cost_key: image_input_cost
80
+
81
+ - key: image_output
82
+ kind: image_token
83
+ direction: output
84
+ modality: image
85
+ cache_state: none
86
+ unit: token
87
+ category: token
88
+ token_key: image_output_tokens
89
+ cost_key: image_output_cost
90
+
71
91
  - key: web_search_request
72
92
  kind: web_search_request
73
93
  direction: neither
@@ -75,6 +95,34 @@
75
95
  cache_state: none
76
96
  unit: request
77
97
  category: tool
98
+ rate_basis: per_1k_requests
99
+
100
+ - key: web_search_preview_request_reasoning
101
+ kind: web_search_preview_request_reasoning
102
+ direction: neither
103
+ modality: text
104
+ cache_state: none
105
+ unit: request
106
+ category: tool
107
+ rate_basis: per_1k_requests
108
+
109
+ - key: web_search_preview_request_non_reasoning
110
+ kind: web_search_preview_request_non_reasoning
111
+ direction: neither
112
+ modality: text
113
+ cache_state: none
114
+ unit: request
115
+ category: tool
116
+ rate_basis: per_1k_requests
117
+
118
+ - key: web_fetch_request
119
+ kind: web_fetch_request
120
+ direction: neither
121
+ modality: text
122
+ cache_state: none
123
+ unit: request
124
+ category: tool
125
+ rate_basis: per_1k_requests
78
126
 
79
127
  - key: file_search_call
80
128
  kind: file_search_call
@@ -83,6 +131,7 @@
83
131
  cache_state: none
84
132
  unit: request
85
133
  category: tool
134
+ rate_basis: per_1k_requests
86
135
 
87
136
  - key: container_session
88
137
  kind: container_session
@@ -91,6 +140,7 @@
91
140
  cache_state: none
92
141
  unit: session
93
142
  category: runtime
143
+ rate_basis: per_session
94
144
 
95
145
  - key: code_execution_request
96
146
  kind: code_execution_request
@@ -99,6 +149,7 @@
99
149
  cache_state: none
100
150
  unit: request
101
151
  category: runtime
152
+ rate_basis: per_1k_requests
102
153
 
103
154
  - key: code_execution_hour
104
155
  kind: code_execution_hour
@@ -107,6 +158,7 @@
107
158
  cache_state: none
108
159
  unit: hour
109
160
  category: runtime
161
+ rate_basis: per_hour
110
162
 
111
163
  - key: grounding_request
112
164
  kind: grounding_request
@@ -115,3 +167,22 @@
115
167
  cache_state: none
116
168
  unit: request
117
169
  category: tool
170
+ rate_basis: per_1k_requests
171
+
172
+ - key: text_to_speech_character
173
+ kind: text_to_speech_character
174
+ direction: output
175
+ modality: audio
176
+ cache_state: none
177
+ unit: character
178
+ category: tool
179
+ rate_basis: per_million_characters
180
+
181
+ - key: mcp_call
182
+ kind: mcp_call
183
+ direction: neither
184
+ modality: text
185
+ cache_state: none
186
+ unit: request
187
+ category: tool
188
+ rate_basis: per_request
@@ -119,7 +119,7 @@ module LlmCostTracker
119
119
  def self.symbol_or_nil(value)
120
120
  return nil if value.nil?
121
121
 
122
- value.is_a?(Symbol) ? value : value.to_s.to_sym
122
+ value.to_s.to_sym
123
123
  end
124
124
 
125
125
  def self.decimal_or_nil(value)
@@ -19,8 +19,11 @@ module LlmCostTracker
19
19
 
20
20
  budgets.each do |budget_type, budget|
21
21
  total = totals.fetch(budget_type)
22
+ next unless total >= budget
22
23
 
23
- handle_exceeded(budget_type: budget_type, total: total, budget: budget) if total >= budget
24
+ raise BudgetExceededError.new(**budget_payload(
25
+ budget_type: budget_type, total: total, budget: budget, last_event: nil
26
+ ))
24
27
  end
25
28
  end
26
29
 
@@ -87,7 +90,6 @@ module LlmCostTracker
87
90
 
88
91
  def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
89
92
  return false unless config.on_budget_exceeded
90
- return true unless config.budget_exceeded_behavior == :notify
91
93
  return true unless last_event&.total_cost
92
94
  return true if budget_type == :per_call
93
95