llm_cost_tracker 0.10.0 → 0.12.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +11 -5
- data/app/assets/llm_cost_tracker/application.css +784 -802
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
- data/config/routes.rb +3 -3
- data/lib/llm_cost_tracker/budget.rb +25 -28
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -61
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +66 -64
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +14 -11
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
- data/lib/llm_cost_tracker/parsers.rb +140 -29
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -295
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -7
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +33 -20
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +92 -58
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -1,183 +0,0 @@
|
|
|
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,16 +0,0 @@
|
|
|
1
|
-
<% if chips.any? %>
|
|
2
|
-
<div class="lct-chip-row" aria-label="Active filters">
|
|
3
|
-
<% chips.each do |chip| %>
|
|
4
|
-
<span class="lct-chip">
|
|
5
|
-
<span class="lct-chip-label"><%= chip[:label] %></span>
|
|
6
|
-
<span><%= chip[:value] %></span>
|
|
7
|
-
<% if chip[:path] %>
|
|
8
|
-
<%= link_to "×", chip[:path], class: "lct-chip-remove", aria: { label: "Remove #{chip[:label]} #{chip[:value]}" } %>
|
|
9
|
-
<% end %>
|
|
10
|
-
</span>
|
|
11
|
-
<% end %>
|
|
12
|
-
<% if local_assigns[:clear_path] %>
|
|
13
|
-
<%= link_to "Clear all", clear_path, class: "lct-clear-link" %>
|
|
14
|
-
<% end %>
|
|
15
|
-
</div>
|
|
16
|
-
<% end %>
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
<%
|
|
2
|
-
fields = local_assigns.fetch(:fields, %i[from to provider model stream])
|
|
3
|
-
default_range = LlmCostTracker::Dashboard::DateRange.call(params: params)
|
|
4
|
-
defaults = {
|
|
5
|
-
from: default_range.from.iso8601,
|
|
6
|
-
to: default_range.to.iso8601
|
|
7
|
-
}.merge(local_assigns.fetch(:defaults, {}))
|
|
8
|
-
reset_path = local_assigns.fetch(:reset_path, path)
|
|
9
|
-
filter_scope = local_assigns.fetch(:filter_scope, params)
|
|
10
|
-
%>
|
|
11
|
-
|
|
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 %>
|
|
16
|
-
<div class="lct-filter-row">
|
|
17
|
-
<% if fields.include?(:from) %>
|
|
18
|
-
<div class="lct-field">
|
|
19
|
-
<label for="lct-filter-from">From</label>
|
|
20
|
-
<input id="lct-filter-from" type="date" name="from" value="<%= params[:from] || defaults[:from] %>">
|
|
21
|
-
</div>
|
|
22
|
-
<% end %>
|
|
23
|
-
|
|
24
|
-
<% if fields.include?(:to) %>
|
|
25
|
-
<div class="lct-field">
|
|
26
|
-
<label for="lct-filter-to">To</label>
|
|
27
|
-
<input id="lct-filter-to" type="date" name="to" value="<%= params[:to] || defaults[:to] %>">
|
|
28
|
-
</div>
|
|
29
|
-
<% end %>
|
|
30
|
-
|
|
31
|
-
<% if fields.include?(:provider) %>
|
|
32
|
-
<div class="lct-field">
|
|
33
|
-
<label for="lct-filter-provider">Provider</label>
|
|
34
|
-
<%= select_tag :provider,
|
|
35
|
-
options_for_select(provider_filter_options(filter_params: filter_scope), params[:provider]),
|
|
36
|
-
include_blank: "All providers",
|
|
37
|
-
id: "lct-filter-provider" %>
|
|
38
|
-
</div>
|
|
39
|
-
<% end %>
|
|
40
|
-
|
|
41
|
-
<% if fields.include?(:model) %>
|
|
42
|
-
<div class="lct-field">
|
|
43
|
-
<label for="lct-filter-model">Model</label>
|
|
44
|
-
<%= select_tag :model,
|
|
45
|
-
options_for_select(model_filter_options(filter_params: filter_scope), params[:model]),
|
|
46
|
-
include_blank: "All models",
|
|
47
|
-
id: "lct-filter-model" %>
|
|
48
|
-
</div>
|
|
49
|
-
<% end %>
|
|
50
|
-
|
|
51
|
-
<% if fields.include?(:stream) %>
|
|
52
|
-
<div class="lct-field">
|
|
53
|
-
<label for="lct-filter-stream">Stream</label>
|
|
54
|
-
<%= select_tag :stream,
|
|
55
|
-
options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
|
|
56
|
-
include_blank: "All calls",
|
|
57
|
-
id: "lct-filter-stream" %>
|
|
58
|
-
</div>
|
|
59
|
-
<% end %>
|
|
60
|
-
|
|
61
|
-
<div class="lct-filter-actions">
|
|
62
|
-
<button class="lct-button" type="submit">Apply</button>
|
|
63
|
-
<%= link_to("Reset", reset_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
64
|
-
</div>
|
|
65
|
-
</div>
|
|
66
|
-
</form>
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<%
|
|
2
|
-
current = local_assigns.fetch(:current).to_s
|
|
3
|
-
options = local_assigns.fetch(:options)
|
|
4
|
-
%>
|
|
5
|
-
|
|
6
|
-
<nav class="lct-sort" aria-label="Sort by">
|
|
7
|
-
<% options.each do |label, value| %>
|
|
8
|
-
<%= link_to label,
|
|
9
|
-
path_for_sort.call(value),
|
|
10
|
-
class: ["lct-sort-option", ("is-active" if current == value.to_s)].compact.join(" "),
|
|
11
|
-
aria: ({ current: "true" } if current == value.to_s) %>
|
|
12
|
-
<% end %>
|
|
13
|
-
</nav>
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "psych"
|
|
4
|
-
|
|
5
|
-
require_relative "../errors"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
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
|
-
|
|
40
|
-
module Components
|
|
41
|
-
Component = Data.define(
|
|
42
|
-
:key,
|
|
43
|
-
:kind,
|
|
44
|
-
:direction,
|
|
45
|
-
:modality,
|
|
46
|
-
:cache_state,
|
|
47
|
-
:unit,
|
|
48
|
-
:category,
|
|
49
|
-
:token_key,
|
|
50
|
-
:cost_key,
|
|
51
|
-
:rate_basis
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
|
|
55
|
-
DEFINITIONS_PATH = File.expand_path("components.yml", __dir__)
|
|
56
|
-
|
|
57
|
-
def self.load_registry
|
|
58
|
-
Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
|
|
59
|
-
.map { |attributes| build(attributes) }
|
|
60
|
-
.freeze
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def self.build(attributes)
|
|
64
|
-
missing = REQUIRED_FIELDS - attributes.keys
|
|
65
|
-
raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
|
|
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
|
-
|
|
76
|
-
Component.new(
|
|
77
|
-
key: attributes.fetch(:key).to_sym,
|
|
78
|
-
kind: attributes.fetch(:kind).to_sym,
|
|
79
|
-
direction: attributes.fetch(:direction).to_sym,
|
|
80
|
-
modality: attributes.fetch(:modality).to_sym,
|
|
81
|
-
cache_state: attributes.fetch(:cache_state).to_sym,
|
|
82
|
-
unit: unit,
|
|
83
|
-
category: attributes.fetch(:category).to_sym,
|
|
84
|
-
token_key: attributes[:token_key]&.to_sym,
|
|
85
|
-
cost_key: attributes[:cost_key]&.to_sym,
|
|
86
|
-
rate_basis: rate_basis
|
|
87
|
-
)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
REGISTRY = load_registry
|
|
91
|
-
BY_KEY = REGISTRY.to_h { |component| [component.key, component] }.freeze
|
|
92
|
-
TOKEN_PRICED = REGISTRY.select { |component| component.token_key && component.cost_key }.freeze
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "check"
|
|
4
|
-
require_relative "../ingestion"
|
|
5
|
-
|
|
6
|
-
module LlmCostTracker
|
|
7
|
-
class Doctor
|
|
8
|
-
class CaptureVerifier
|
|
9
|
-
class << self
|
|
10
|
-
def call
|
|
11
|
-
new.checks
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def report(checks = call)
|
|
15
|
-
(["LLM Cost Tracker capture verification"] + checks.map do |check|
|
|
16
|
-
"[#{check.status}] #{check.name}: #{check.message}"
|
|
17
|
-
end).join("\n")
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def healthy?(checks = call)
|
|
21
|
-
checks.none? { |check| check.status == :error }
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def checks
|
|
26
|
-
[
|
|
27
|
-
enabled_check,
|
|
28
|
-
*integration_checks,
|
|
29
|
-
*storage_checks
|
|
30
|
-
].compact
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def enabled_check
|
|
36
|
-
return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
|
|
37
|
-
|
|
38
|
-
Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def integration_checks
|
|
42
|
-
enabled = LlmCostTracker.configuration.instrumented_integrations
|
|
43
|
-
if enabled.empty?
|
|
44
|
-
return [
|
|
45
|
-
Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
|
|
46
|
-
]
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
LlmCostTracker::Integrations.checks.map do |check|
|
|
50
|
-
check.with(name: "sdk integration #{check.name}")
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def storage_checks
|
|
55
|
-
LlmCostTracker::Ingestion.verify
|
|
56
|
-
rescue LlmCostTracker::Error => e
|
|
57
|
-
[Check.new(:error, "storage", e.message)]
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bigdecimal"
|
|
4
|
-
|
|
5
|
-
require_relative "check"
|
|
6
|
-
require_relative "probe"
|
|
7
|
-
require_relative "../ledger/rollups"
|
|
8
|
-
|
|
9
|
-
module LlmCostTracker
|
|
10
|
-
class Doctor
|
|
11
|
-
class CostDriftCheck
|
|
12
|
-
SAMPLE_SIZE = 200
|
|
13
|
-
EPSILON = BigDecimal("0.00000001")
|
|
14
|
-
|
|
15
|
-
def call
|
|
16
|
-
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
17
|
-
return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
|
|
18
|
-
|
|
19
|
-
sampled = LlmCostTracker::Call
|
|
20
|
-
.where.not(total_cost: nil)
|
|
21
|
-
.where(cost_status: %w[complete free partial])
|
|
22
|
-
.order(id: :desc)
|
|
23
|
-
.limit(SAMPLE_SIZE)
|
|
24
|
-
.pluck(:id, :total_cost, :cost_status)
|
|
25
|
-
return Check.new(:ok, "cost drift", "no priced calls to inspect") if sampled.empty?
|
|
26
|
-
|
|
27
|
-
line_item_totals = LlmCostTracker::CallLineItem
|
|
28
|
-
.where(llm_cost_tracker_call_id: sampled.map(&:first))
|
|
29
|
-
.where(currency: Ledger::Rollups::DEFAULT_CURRENCY)
|
|
30
|
-
.group(:llm_cost_tracker_call_id)
|
|
31
|
-
.sum(:cost)
|
|
32
|
-
|
|
33
|
-
drifted = sampled.filter_map do |id, total_cost, cost_status|
|
|
34
|
-
line_total = line_item_totals[id] || BigDecimal("0")
|
|
35
|
-
header = BigDecimal(total_cost.to_s)
|
|
36
|
-
next if cost_status == "partial" && header >= line_total
|
|
37
|
-
next if (header - line_total).abs <= EPSILON
|
|
38
|
-
|
|
39
|
-
"##{id}: header=#{header.to_s('F')} line_items=#{line_total.to_s('F')}"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
if drifted.empty?
|
|
43
|
-
return Check.new(:ok, "cost drift",
|
|
44
|
-
"header total_cost matches line items in #{sampled.size} sampled calls")
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
Check.new(
|
|
48
|
-
:warn,
|
|
49
|
-
"cost drift",
|
|
50
|
-
"header total_cost diverges from line items in #{drifted.size}/#{sampled.size} sampled calls: " \
|
|
51
|
-
"#{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
|
|
52
|
-
)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bigdecimal"
|
|
4
|
-
|
|
5
|
-
require_relative "check"
|
|
6
|
-
require_relative "probe"
|
|
7
|
-
require_relative "../ledger/schema/adapter"
|
|
8
|
-
|
|
9
|
-
module LlmCostTracker
|
|
10
|
-
class Doctor
|
|
11
|
-
class InvoiceReconciliationCheck
|
|
12
|
-
def call
|
|
13
|
-
return unless LlmCostTracker.reconciliation_enabled?
|
|
14
|
-
return unless Probe.table_exists?("llm_cost_tracker_provider_invoices")
|
|
15
|
-
return if no_imports?
|
|
16
|
-
|
|
17
|
-
scopes = imported_scopes
|
|
18
|
-
return Check.new(:ok, "invoice reconciliation", "no provider invoices imported yet") if scopes.empty?
|
|
19
|
-
|
|
20
|
-
non_canonical = non_canonical_currency_check
|
|
21
|
-
checks = scopes.map { |scope| check_scope_safely(scope) }
|
|
22
|
-
checks << non_canonical if non_canonical
|
|
23
|
-
checks
|
|
24
|
-
rescue StandardError => e
|
|
25
|
-
Check.new(:error, "invoice reconciliation", e.message)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def no_imports?
|
|
31
|
-
LlmCostTracker::ProviderInvoice.none?
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def non_canonical_currency_check
|
|
35
|
-
legacy = LlmCostTracker::ProviderInvoice.where("currency <> UPPER(currency)").count
|
|
36
|
-
return nil if legacy.zero?
|
|
37
|
-
|
|
38
|
-
Check.new(
|
|
39
|
-
:warn,
|
|
40
|
-
"invoice reconciliation: currency canonicalisation",
|
|
41
|
-
"#{legacy} provider invoice row(s) stored with non-uppercase currency. Diff queries " \
|
|
42
|
-
"are case-sensitive — run " \
|
|
43
|
-
"`UPDATE llm_cost_tracker_provider_invoices SET currency = UPPER(currency);` to backfill."
|
|
44
|
-
)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def threshold
|
|
48
|
-
Reconciliation::DEFAULT_THRESHOLD_PERCENT
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def imported_scopes
|
|
52
|
-
connection = LlmCostTracker::ProviderInvoice.connection
|
|
53
|
-
provider_expr =
|
|
54
|
-
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
55
|
-
Arel.sql("metadata->>'provider'")
|
|
56
|
-
else
|
|
57
|
-
Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
|
|
58
|
-
end
|
|
59
|
-
LlmCostTracker::ProviderInvoice
|
|
60
|
-
.group(:source, provider_expr, :currency)
|
|
61
|
-
.order(:source, :currency)
|
|
62
|
-
.pluck(:source, provider_expr, :currency)
|
|
63
|
-
.map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def scope_label(scope)
|
|
67
|
-
"#{scope[:source]}/#{scope[:provider]}/#{scope[:currency]}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def check_scope_safely(scope)
|
|
71
|
-
check_scope(scope)
|
|
72
|
-
rescue ArgumentError => e
|
|
73
|
-
Check.new(:warn, "invoice reconciliation: #{scope_label(scope)}", e.message)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def check_scope(scope)
|
|
77
|
-
window = latest_window_for(scope)
|
|
78
|
-
return stale_check(scope) if window.nil?
|
|
79
|
-
|
|
80
|
-
diff = run_diff(scope, window)
|
|
81
|
-
return ok_check(scope, window, diff) if diff.aligned?(threshold_percent: threshold)
|
|
82
|
-
|
|
83
|
-
warn_check(scope, window, diff)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def scope_relation(scope)
|
|
87
|
-
relation = LlmCostTracker::ProviderInvoice
|
|
88
|
-
.where(source: scope[:source], currency: scope[:currency])
|
|
89
|
-
provider = scope[:provider]
|
|
90
|
-
return relation if provider.nil? || provider.to_s.empty?
|
|
91
|
-
|
|
92
|
-
connection = LlmCostTracker::ProviderInvoice.connection
|
|
93
|
-
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
94
|
-
relation.where("metadata->>'provider' = ?", provider)
|
|
95
|
-
else
|
|
96
|
-
relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def latest_window_for(scope)
|
|
101
|
-
latest = scope_relation(scope)
|
|
102
|
-
.select(:period_start, :period_end)
|
|
103
|
-
.order(period_end: :desc, period_start: :desc)
|
|
104
|
-
.limit(1)
|
|
105
|
-
.first
|
|
106
|
-
return nil unless latest
|
|
107
|
-
return nil if (Time.now.utc.to_date - latest.period_end).to_i > Reconciliation::INVOICE_FRESHNESS_DAYS
|
|
108
|
-
|
|
109
|
-
latest
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def run_diff(scope, window)
|
|
113
|
-
Reconciliation.diff(
|
|
114
|
-
source: scope[:source],
|
|
115
|
-
provider: scope[:provider],
|
|
116
|
-
currency: scope[:currency],
|
|
117
|
-
period_start: window.period_start,
|
|
118
|
-
period_end: window.period_end
|
|
119
|
-
)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def stale_check(scope)
|
|
123
|
-
latest = scope_relation(scope).maximum(:period_end)
|
|
124
|
-
return scope_unreachable_check(scope) if latest.nil?
|
|
125
|
-
|
|
126
|
-
days = (Time.now.utc.to_date - latest).to_i
|
|
127
|
-
Check.new(
|
|
128
|
-
:warn,
|
|
129
|
-
"invoice reconciliation: #{scope_label(scope)}",
|
|
130
|
-
"no invoice imported in #{days} days (threshold #{Reconciliation::INVOICE_FRESHNESS_DAYS} days); " \
|
|
131
|
-
"run reconciliation import"
|
|
132
|
-
)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def scope_unreachable_check(scope)
|
|
136
|
-
Check.new(
|
|
137
|
-
:warn,
|
|
138
|
-
"invoice reconciliation: #{scope_label(scope)}",
|
|
139
|
-
"scope grouped invoices but the filter (likely currency case mismatch) matches zero rows; " \
|
|
140
|
-
"the currency-canonicalisation check below points at the backfill SQL"
|
|
141
|
-
)
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def ok_check(scope, window, diff)
|
|
145
|
-
Check.new(
|
|
146
|
-
:ok,
|
|
147
|
-
"invoice reconciliation: #{scope_label(scope)}",
|
|
148
|
-
"#{window.period_start}..#{window.period_end} aligned " \
|
|
149
|
-
"(local=#{diff.local_total.to_s('F')}, provider=#{diff.provider_total.to_s('F')})"
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def warn_check(scope, window, diff)
|
|
154
|
-
Check.new(
|
|
155
|
-
:warn,
|
|
156
|
-
"invoice reconciliation: #{scope_label(scope)}",
|
|
157
|
-
"#{window.period_start}..#{window.period_end} drift " \
|
|
158
|
-
"delta=#{diff.delta_amount.to_s('F')} (#{diff.delta_percent}%) " \
|
|
159
|
-
"exceeds #{threshold}% threshold"
|
|
160
|
-
)
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
end
|