llm_cost_tracker 0.8.0 → 0.10.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 +136 -0
- data/README.md +14 -6
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
- data/lib/llm_cost_tracker/budget.rb +31 -7
- data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +72 -17
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -14
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +48 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
- data/lib/llm_cost_tracker/ingestion.rb +48 -11
- data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
- data/lib/llm_cost_tracker/integrations/base.rb +35 -15
- data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
- data/lib/llm_cost_tracker/integrations.rb +33 -14
- data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
- data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
- data/lib/llm_cost_tracker/ledger/store.rb +34 -31
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
- data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -43
- data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +572 -493
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
- data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +117 -44
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +8 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +31 -6
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
- data/lib/llm_cost_tracker/token_usage.rb +14 -2
- data/lib/llm_cost_tracker/tracker.rb +41 -55
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +19 -14
- data/lib/tasks/llm_cost_tracker.rake +41 -4
- metadata +49 -3
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -13,6 +13,9 @@ require_relative "doctor/pricing_snapshot_drift_check"
|
|
|
13
13
|
|
|
14
14
|
module LlmCostTracker
|
|
15
15
|
class Doctor
|
|
16
|
+
autoload :InvoiceReconciliationCheck, "llm_cost_tracker/doctor/invoice_reconciliation_check"
|
|
17
|
+
autoload :CaptureVerifier, "llm_cost_tracker/doctor/capture_verifier"
|
|
18
|
+
|
|
16
19
|
class << self
|
|
17
20
|
def call
|
|
18
21
|
new.checks
|
|
@@ -33,7 +36,7 @@ module LlmCostTracker
|
|
|
33
36
|
[
|
|
34
37
|
configuration_check,
|
|
35
38
|
capture_check,
|
|
36
|
-
*
|
|
39
|
+
*LlmCostTracker::Integrations.checks,
|
|
37
40
|
active_record_check,
|
|
38
41
|
table_check,
|
|
39
42
|
column_check,
|
|
@@ -41,10 +44,10 @@ module LlmCostTracker
|
|
|
41
44
|
table: "llm_cost_tracker_call_line_items").call,
|
|
42
45
|
SchemaCheck.new(name: "call tags", schema: Ledger::Schema::CallTags,
|
|
43
46
|
table: "llm_cost_tracker_call_tags").call,
|
|
44
|
-
|
|
45
|
-
table: "llm_cost_tracker_provider_invoices").call,
|
|
47
|
+
*reconciliation_schema_checks,
|
|
46
48
|
CostDriftCheck.new.call,
|
|
47
49
|
PricingSnapshotDriftCheck.new.call,
|
|
50
|
+
*reconciliation_invoice_check,
|
|
48
51
|
LegacyBillingStatusCheck.new.call,
|
|
49
52
|
LegacyAuditCheck.new.call,
|
|
50
53
|
call_rollups_check,
|
|
@@ -56,6 +59,22 @@ module LlmCostTracker
|
|
|
56
59
|
|
|
57
60
|
private
|
|
58
61
|
|
|
62
|
+
def reconciliation_schema_checks
|
|
63
|
+
return [] unless LlmCostTracker.reconciliation_enabled?
|
|
64
|
+
|
|
65
|
+
Reconciliation::SCHEMA_TABLES.map do |schema, table|
|
|
66
|
+
SchemaCheck.new(name: table.delete_prefix("llm_cost_tracker_").tr("_", " "),
|
|
67
|
+
schema: schema, table: table,
|
|
68
|
+
optional: false, install_command: "llm_cost_tracker:reconciliation").call
|
|
69
|
+
end.compact
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reconciliation_invoice_check
|
|
73
|
+
return [] unless LlmCostTracker.reconciliation_enabled?
|
|
74
|
+
|
|
75
|
+
Array(InvoiceReconciliationCheck.new.call)
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
def configuration_check
|
|
60
79
|
config = LlmCostTracker.configuration
|
|
61
80
|
Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
|
|
@@ -82,12 +101,6 @@ module LlmCostTracker
|
|
|
82
101
|
)
|
|
83
102
|
end
|
|
84
103
|
|
|
85
|
-
def integration_checks
|
|
86
|
-
LlmCostTracker::Integrations.checks.map do |check|
|
|
87
|
-
Check.new(check.status, check.name.to_s, check.message)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
104
|
def active_record_check
|
|
92
105
|
return Check.new(:ok, "active_record", "available") if active_record_available?
|
|
93
106
|
|
|
@@ -120,9 +133,10 @@ module LlmCostTracker
|
|
|
120
133
|
|
|
121
134
|
def call_rollups_check
|
|
122
135
|
return unless llm_cost_tracker_calls_table?
|
|
136
|
+
return live_rollups_check unless LlmCostTracker.configuration.cache_rollups
|
|
123
137
|
|
|
124
138
|
errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
|
|
125
|
-
return
|
|
139
|
+
return rollups_drift_check if errors.empty?
|
|
126
140
|
|
|
127
141
|
Check.new(
|
|
128
142
|
:error,
|
|
@@ -131,6 +145,52 @@ module LlmCostTracker
|
|
|
131
145
|
)
|
|
132
146
|
end
|
|
133
147
|
|
|
148
|
+
ROLLUPS_DRIFT_TOLERANCE_PERCENT = 1.0
|
|
149
|
+
private_constant :ROLLUPS_DRIFT_TOLERANCE_PERCENT
|
|
150
|
+
|
|
151
|
+
def rollups_drift_check
|
|
152
|
+
drift_window = Time.now.utc.beginning_of_day
|
|
153
|
+
calls_total = LlmCostTracker::Call
|
|
154
|
+
.where(tracked_at: drift_window..)
|
|
155
|
+
.where.not(total_cost: nil)
|
|
156
|
+
.sum(:total_cost)
|
|
157
|
+
rollup_total = LlmCostTracker::CallRollup
|
|
158
|
+
.where(period: "day", period_start: drift_window.to_date)
|
|
159
|
+
.sum(:total_cost)
|
|
160
|
+
return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if calls_total.zero?
|
|
161
|
+
|
|
162
|
+
drift_percent = ((calls_total - rollup_total).abs * 100.0 / calls_total)
|
|
163
|
+
if drift_percent > ROLLUPS_DRIFT_TOLERANCE_PERCENT
|
|
164
|
+
return Check.new(
|
|
165
|
+
:warn, "call rollups",
|
|
166
|
+
"rollups drift detected: today's calls SUM=#{calls_total} vs rollups SUM=#{rollup_total} " \
|
|
167
|
+
"(#{drift_percent.round(2)}% > #{ROLLUPS_DRIFT_TOLERANCE_PERCENT}% threshold). " \
|
|
168
|
+
"Cached budget reads may understate spend until a rebuild."
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists")
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
Check.new(:warn, "call rollups", "rollups drift check failed: #{e.class}: #{e.message}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def live_rollups_check
|
|
178
|
+
if Probe.table_exists?("llm_cost_tracker_call_rollups")
|
|
179
|
+
Check.new(
|
|
180
|
+
:warn,
|
|
181
|
+
"call rollups",
|
|
182
|
+
"cache_rollups=false but llm_cost_tracker_call_rollups exists. " \
|
|
183
|
+
"Set config.cache_rollups = true to keep budget reads on the rollups fast path or drop the table."
|
|
184
|
+
)
|
|
185
|
+
else
|
|
186
|
+
Check.new(
|
|
187
|
+
:ok,
|
|
188
|
+
"call rollups",
|
|
189
|
+
"cache_rollups=false; budget reads aggregate from llm_cost_tracker_calls directly"
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
134
194
|
def calls_check
|
|
135
195
|
return unless llm_cost_tracker_calls_table?
|
|
136
196
|
|
|
@@ -140,16 +200,14 @@ module LlmCostTracker
|
|
|
140
200
|
count = snapshot.tracked_call_count.to_i
|
|
141
201
|
return Check.new(:warn, "tracked calls", "none recorded yet") if count.zero?
|
|
142
202
|
|
|
143
|
-
|
|
144
|
-
latest_at = latest_at.to_time if latest_at.respond_to?(:to_time)
|
|
145
|
-
latest = latest_at&.utc&.iso8601
|
|
203
|
+
latest = snapshot.latest_tracked_at.to_time.utc.iso8601
|
|
146
204
|
Check.new(:ok, "tracked calls", "#{count} recorded; latest #{latest}")
|
|
147
205
|
end
|
|
148
206
|
|
|
149
207
|
def active_record_available?
|
|
150
208
|
LlmCostTracker::Call.connection
|
|
151
209
|
true
|
|
152
|
-
rescue
|
|
210
|
+
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
|
153
211
|
false
|
|
154
212
|
end
|
|
155
213
|
|
|
@@ -8,5 +8,13 @@ require "rack/files"
|
|
|
8
8
|
module LlmCostTracker
|
|
9
9
|
class Engine < ::Rails::Engine
|
|
10
10
|
isolate_namespace LlmCostTracker
|
|
11
|
+
|
|
12
|
+
initializer "llm_cost_tracker.filter_parameters" do |app|
|
|
13
|
+
app.config.filter_parameters += %i[tag tag_value]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "llm_cost_tracker.dashboard_setup_state" do |app|
|
|
17
|
+
app.reloader.to_prepare { LlmCostTracker::Dashboard::SetupState.reset! }
|
|
18
|
+
end
|
|
11
19
|
end
|
|
12
20
|
end
|
|
@@ -6,13 +6,14 @@ module LlmCostTracker
|
|
|
6
6
|
class InvalidFilterError < Error; end
|
|
7
7
|
|
|
8
8
|
class BudgetExceededError < Error
|
|
9
|
-
attr_reader :total, :budget, :budget_type, :last_event
|
|
9
|
+
attr_reader :total, :budget, :budget_type, :last_event, :stage
|
|
10
10
|
|
|
11
|
-
def initialize(budget:, budget_type:, total:, last_event: nil)
|
|
11
|
+
def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend)
|
|
12
12
|
@total = total
|
|
13
13
|
@budget = budget
|
|
14
14
|
@budget_type = budget_type
|
|
15
15
|
@last_event = last_event
|
|
16
|
+
@stage = stage
|
|
16
17
|
|
|
17
18
|
super(
|
|
18
19
|
"LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded: " \
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "pricing"
|
|
4
|
+
require_relative "billing/line_item"
|
|
5
|
+
|
|
3
6
|
module LlmCostTracker
|
|
4
7
|
Event = Data.define(
|
|
5
8
|
:event_id,
|
|
@@ -22,6 +25,46 @@ module LlmCostTracker
|
|
|
22
25
|
:pricing_snapshot,
|
|
23
26
|
:line_items
|
|
24
27
|
) do
|
|
28
|
+
def self.batch_from_pricing_mode?(pricing_mode)
|
|
29
|
+
pricing_mode.to_s.split("_").include?("batch")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build(**attributes)
|
|
33
|
+
pricing_mode = Pricing.normalize_mode(attributes[:pricing_mode])
|
|
34
|
+
token_usage = attributes.fetch(:token_usage)
|
|
35
|
+
batch = attributes[:batch].nil? ? batch_from_pricing_mode?(pricing_mode) : attributes[:batch]
|
|
36
|
+
line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items], token_usage)
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
event_id: attributes[:event_id],
|
|
40
|
+
provider: attributes.fetch(:provider).to_s,
|
|
41
|
+
model: attributes.fetch(:model).to_s.strip.presence || Event::UNKNOWN_MODEL,
|
|
42
|
+
token_usage: token_usage,
|
|
43
|
+
pricing_mode: pricing_mode,
|
|
44
|
+
cost: attributes[:cost],
|
|
45
|
+
tags: attributes[:tags],
|
|
46
|
+
latency_ms: attributes[:latency_ms],
|
|
47
|
+
stream: attributes[:stream] || false,
|
|
48
|
+
usage_source: attributes[:usage_source],
|
|
49
|
+
provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
|
|
50
|
+
provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
|
|
51
|
+
provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
|
|
52
|
+
provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
|
|
53
|
+
batch: batch,
|
|
54
|
+
tracked_at: attributes[:tracked_at],
|
|
55
|
+
cost_status: attributes[:cost_status],
|
|
56
|
+
pricing_snapshot: attributes[:pricing_snapshot],
|
|
57
|
+
line_items: line_items
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.resolve_line_items(service_items, token_usage)
|
|
62
|
+
service_line_items = Array(service_items).map do |item|
|
|
63
|
+
item.is_a?(Billing::LineItem) ? item : Billing::LineItem.build(item)
|
|
64
|
+
end
|
|
65
|
+
Billing::LineItem.from_token_usage(token_usage) + service_line_items
|
|
66
|
+
end
|
|
67
|
+
|
|
25
68
|
def total_cost
|
|
26
69
|
cost&.fetch(:total_cost, nil)
|
|
27
70
|
end
|
|
@@ -29,10 +72,14 @@ module LlmCostTracker
|
|
|
29
72
|
def to_h
|
|
30
73
|
super.merge(
|
|
31
74
|
token_usage: token_usage.to_h,
|
|
32
|
-
cost: cost
|
|
75
|
+
cost: cost && cost.to_h.transform_values { |v| v.is_a?(BigDecimal) ? v.to_f : v },
|
|
33
76
|
tags: tags ? tags.to_h : {},
|
|
34
77
|
line_items: (line_items || []).map(&:to_h)
|
|
35
78
|
)
|
|
36
79
|
end
|
|
37
80
|
end
|
|
81
|
+
|
|
82
|
+
class Event
|
|
83
|
+
UNKNOWN_MODEL = "unknown"
|
|
84
|
+
end
|
|
38
85
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AsyncIngestionGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
|
|
14
|
+
"Required when config.ingestion = :async."
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template(
|
|
18
|
+
"create_llm_cost_tracker_async_ingestion.rb.erb",
|
|
19
|
+
"db/migrate/create_llm_cost_tracker_async_ingestion.rb"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def warn_about_config_flag
|
|
24
|
+
say(<<~MSG, :yellow)
|
|
25
|
+
After migrating, set the following in config/initializers/llm_cost_tracker.rb:
|
|
26
|
+
|
|
27
|
+
LlmCostTracker.configure do |config|
|
|
28
|
+
config.ingestion = :async
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Without it the async inbox tables stay unused and Tracker keeps writing
|
|
32
|
+
inline. The doctor check warns about unused async ingestion tables.
|
|
33
|
+
MSG
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def migration_version
|
|
39
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class CallRollupsGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
|
|
14
|
+
"Required when config.cache_rollups = true."
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template(
|
|
18
|
+
"create_llm_cost_tracker_call_rollups.rb.erb",
|
|
19
|
+
"db/migrate/create_llm_cost_tracker_call_rollups.rb"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def warn_about_config_flag
|
|
24
|
+
say(<<~MSG, :yellow)
|
|
25
|
+
After migrating, set the following in config/initializers/llm_cost_tracker.rb:
|
|
26
|
+
|
|
27
|
+
LlmCostTracker.configure do |config|
|
|
28
|
+
config.cache_rollups = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Without it Tracker keeps reading budget totals as live SUM aggregates over
|
|
32
|
+
llm_cost_tracker_calls. The doctor check warns about an unused rollups table.
|
|
33
|
+
MSG
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def migration_version
|
|
39
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -26,24 +26,33 @@ module LlmCostTracker
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def create_initializer
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
)
|
|
29
|
+
destination = "config/initializers/llm_cost_tracker.rb"
|
|
30
|
+
return if File.exist?(File.join(destination_root, destination))
|
|
31
|
+
|
|
32
|
+
template("initializer.rb.erb", destination)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def create_prices_file
|
|
36
36
|
return unless options[:prices]
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
require_relative "prices_generator"
|
|
39
|
+
invoke LlmCostTracker::Generators::PricesGenerator
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def mount_engine
|
|
42
43
|
return unless options[:dashboard]
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
say(<<~MSG, :yellow)
|
|
46
|
+
The LLM Cost Tracker dashboard ships without authentication.
|
|
47
|
+
Mount it in config/routes.rb behind your app's admin auth, e.g.:
|
|
48
|
+
|
|
49
|
+
authenticate :admin do
|
|
50
|
+
mount LlmCostTracker::Engine => "/llm-costs"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
The generator does NOT add a route automatically — leaving the dashboard
|
|
54
|
+
unauthenticated would expose spend, tags, and provider IDs to anyone.
|
|
55
|
+
MSG
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
private
|
|
@@ -51,24 +60,6 @@ module LlmCostTracker
|
|
|
51
60
|
def migration_version
|
|
52
61
|
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
53
62
|
end
|
|
54
|
-
|
|
55
|
-
def add_engine_require
|
|
56
|
-
return unless File.exist?("config/application.rb")
|
|
57
|
-
|
|
58
|
-
contents = File.read("config/application.rb")
|
|
59
|
-
return if contents.include?(%(require "llm_cost_tracker/engine"))
|
|
60
|
-
|
|
61
|
-
unless contents.include?(%(require "rails/all"\n))
|
|
62
|
-
prepend_to_file("config/application.rb", %(require "llm_cost_tracker/engine"\n))
|
|
63
|
-
return
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
inject_into_file(
|
|
67
|
-
"config/application.rb",
|
|
68
|
-
%(require "llm_cost_tracker/engine"\n),
|
|
69
|
-
after: %(require "rails/all"\n)
|
|
70
|
-
)
|
|
71
|
-
end
|
|
72
63
|
end
|
|
73
64
|
end
|
|
74
65
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class ReconciliationGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates the optional invoice reconciliation tables. Requires provider admin/org-level API keys."
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"create_llm_cost_tracker_reconciliation.rb.erb",
|
|
18
|
+
"db/migrate/create_llm_cost_tracker_reconciliation.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def warn_about_admin_keys
|
|
23
|
+
say "Reconciliation requires admin/org-level API keys (OpenAI sk-admin-..., Anthropic admin keys, " \
|
|
24
|
+
"GCP billing.viewer service accounts). Do NOT use the runtime inference key.", :yellow
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def migration_version
|
|
30
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
|
|
4
|
+
t.string :event_id, null: false
|
|
5
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
6
|
+
t.datetime :tracked_at, null: false
|
|
7
|
+
t.text :payload, null: false, limit: 16.megabytes
|
|
8
|
+
t.datetime :locked_at
|
|
9
|
+
t.string :locked_by
|
|
10
|
+
t.integer :attempts, null: false, default: 0
|
|
11
|
+
t.text :last_error, limit: 16.megabytes
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
create_table :llm_cost_tracker_ingestion_leases do |t|
|
|
17
|
+
t.string :name, null: false
|
|
18
|
+
t.string :locked_by
|
|
19
|
+
t.datetime :locked_until
|
|
20
|
+
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
|
|
25
|
+
add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
|
|
26
|
+
add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
|
|
27
|
+
add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateLlmCostTrackerCallRollups < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :llm_cost_tracker_call_rollups do |t|
|
|
4
|
+
t.string :period, null: false
|
|
5
|
+
t.date :period_start, null: false
|
|
6
|
+
t.string :currency, null: false, default: "USD"
|
|
7
|
+
t.string :provider, null: false, default: ""
|
|
8
|
+
t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :llm_cost_tracker_call_rollups, [:period, :period_start, :currency, :provider], unique: true
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
CHANGED
|
@@ -34,36 +34,6 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
34
34
|
t.timestamps
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
create_table :llm_cost_tracker_call_rollups do |t|
|
|
38
|
-
t.string :period, null: false
|
|
39
|
-
t.date :period_start, null: false
|
|
40
|
-
t.string :currency, null: false, default: "USD"
|
|
41
|
-
t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
|
|
42
|
-
|
|
43
|
-
t.timestamps
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
|
|
47
|
-
t.string :event_id, null: false
|
|
48
|
-
t.decimal :total_cost, precision: 20, scale: 8
|
|
49
|
-
t.datetime :tracked_at, null: false
|
|
50
|
-
t.text :payload, null: false
|
|
51
|
-
t.datetime :locked_at
|
|
52
|
-
t.string :locked_by
|
|
53
|
-
t.integer :attempts, null: false, default: 0
|
|
54
|
-
t.text :last_error
|
|
55
|
-
|
|
56
|
-
t.timestamps
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
create_table :llm_cost_tracker_ingestion_leases do |t|
|
|
60
|
-
t.string :name, null: false
|
|
61
|
-
t.string :locked_by
|
|
62
|
-
t.datetime :locked_until
|
|
63
|
-
|
|
64
|
-
t.timestamps
|
|
65
|
-
end
|
|
66
|
-
|
|
67
37
|
create_table :llm_cost_tracker_call_line_items do |t|
|
|
68
38
|
t.references :llm_cost_tracker_call,
|
|
69
39
|
null: false,
|
|
@@ -107,25 +77,6 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
107
77
|
t.text :value, null: false
|
|
108
78
|
end
|
|
109
79
|
|
|
110
|
-
create_table :llm_cost_tracker_provider_invoices do |t|
|
|
111
|
-
t.string :source, null: false
|
|
112
|
-
t.date :period_start, null: false
|
|
113
|
-
t.date :period_end, null: false
|
|
114
|
-
t.string :external_id, null: false
|
|
115
|
-
t.decimal :billed_amount, precision: 20, scale: 8
|
|
116
|
-
t.string :currency, null: false, default: "USD"
|
|
117
|
-
if postgresql?
|
|
118
|
-
t.jsonb :metadata, null: false, default: {}
|
|
119
|
-
elsif mysql?
|
|
120
|
-
t.json :metadata, null: false
|
|
121
|
-
else
|
|
122
|
-
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
123
|
-
end
|
|
124
|
-
t.datetime :imported_at, null: false
|
|
125
|
-
|
|
126
|
-
t.timestamps
|
|
127
|
-
end
|
|
128
|
-
|
|
129
80
|
add_index :llm_cost_tracker_calls, :event_id, unique: true
|
|
130
81
|
add_index :llm_cost_tracker_calls, :tracked_at
|
|
131
82
|
add_index :llm_cost_tracker_calls, [:provider, :tracked_at]
|
|
@@ -133,16 +84,12 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
133
84
|
add_index :llm_cost_tracker_calls, :cost_status
|
|
134
85
|
add_index :llm_cost_tracker_calls, :provider_response_id
|
|
135
86
|
add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
|
|
136
|
-
add_index :llm_cost_tracker_call_line_items, :kind
|
|
137
87
|
add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
|
|
144
|
-
add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true
|
|
145
|
-
add_index :llm_cost_tracker_provider_invoices, [:source, :period_start]
|
|
88
|
+
if postgresql?
|
|
89
|
+
add_index :llm_cost_tracker_call_tags, [:key, :value]
|
|
90
|
+
elsif mysql?
|
|
91
|
+
add_index :llm_cost_tracker_call_tags, [:key, :value], length: { value: 191 }
|
|
92
|
+
end
|
|
146
93
|
end
|
|
147
94
|
|
|
148
95
|
private
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :llm_cost_tracker_provider_invoices, if_not_exists: true do |t|
|
|
6
|
+
t.string :source, null: false
|
|
7
|
+
t.date :period_start, null: false
|
|
8
|
+
t.date :period_end, null: false
|
|
9
|
+
t.string :external_id, null: false
|
|
10
|
+
t.decimal :billed_amount, precision: 20, scale: 8
|
|
11
|
+
t.string :currency, null: false, default: "USD"
|
|
12
|
+
if postgresql?
|
|
13
|
+
t.jsonb :metadata, null: false, default: {}
|
|
14
|
+
elsif mysql?
|
|
15
|
+
t.json :metadata, null: false
|
|
16
|
+
else
|
|
17
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
18
|
+
end
|
|
19
|
+
t.datetime :imported_at, null: false
|
|
20
|
+
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
|
|
25
|
+
t.string :source, null: false
|
|
26
|
+
t.string :provider, null: false, default: ""
|
|
27
|
+
t.string :cursor
|
|
28
|
+
t.date :window_start
|
|
29
|
+
t.date :window_end
|
|
30
|
+
t.string :state, null: false
|
|
31
|
+
t.text :last_error
|
|
32
|
+
t.integer :rows_imported, null: false, default: 0
|
|
33
|
+
t.datetime :started_at, null: false
|
|
34
|
+
t.datetime :finished_at
|
|
35
|
+
|
|
36
|
+
t.timestamps
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true,
|
|
40
|
+
if_not_exists: true
|
|
41
|
+
add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
|
|
42
|
+
if_not_exists: true
|
|
43
|
+
if postgresql?
|
|
44
|
+
add_index :llm_cost_tracker_provider_invoices, :metadata, using: :gin,
|
|
45
|
+
if_not_exists: true
|
|
46
|
+
end
|
|
47
|
+
add_index :llm_cost_tracker_provider_invoice_imports, %i[source provider started_at],
|
|
48
|
+
if_not_exists: true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def postgresql?
|
|
54
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def mysql?
|
|
58
|
+
LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
|
|
59
|
+
end
|
|
60
|
+
end
|