llm_cost_tracker 0.11.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 +55 -0
- data/README.md +7 -4
- data/app/assets/llm_cost_tracker/application.css +8 -7
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
- 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 +30 -44
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
- data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
- data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
- data/config/routes.rb +2 -3
- data/lib/llm_cost_tracker/budget.rb +24 -26
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/capture/sse.rb +1 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
- 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 -44
- 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 +5 -69
- 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/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
- 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 +92 -106
- 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 +70 -276
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
- 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 +8 -18
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
- data/lib/llm_cost_tracker/parsers.rb +139 -26
- 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 +40 -52
- 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 -278
- 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 +63 -39
- 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 -3
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +1 -1
- 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 +81 -55
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
- 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 -174
- 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 -36
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
- 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 -176
- 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 -230
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
- 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/server_tools.rb +0 -15
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
- 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 -249
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -11,11 +11,14 @@ module LlmCostTracker
|
|
|
11
11
|
class PricesGenerator < Rails::Generators::Base
|
|
12
12
|
desc "Creates a local LLM Cost Tracker price snapshot"
|
|
13
13
|
|
|
14
|
+
PRICES_PATH = "config/llm_cost_tracker_prices.yml"
|
|
15
|
+
|
|
14
16
|
def create_prices_file
|
|
15
|
-
LlmCostTracker::Pricing::Sync::RegistryWriter.new.
|
|
16
|
-
path: File.join(destination_root,
|
|
17
|
+
payload = LlmCostTracker::Pricing::Sync::RegistryWriter.new.render(
|
|
18
|
+
path: File.join(destination_root, PRICES_PATH),
|
|
17
19
|
registry: YAML.safe_load_file(LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
|
|
18
20
|
)
|
|
21
|
+
create_file(PRICES_PATH, payload)
|
|
19
22
|
end
|
|
20
23
|
end
|
|
21
24
|
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
require "llm_cost_tracker/
|
|
2
|
-
require "llm_cost_tracker/billing/cost_status"
|
|
1
|
+
require "llm_cost_tracker/charges/cost_status"
|
|
3
2
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
4
3
|
|
|
5
4
|
class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %>
|
|
@@ -8,7 +7,7 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
8
7
|
t.string :event_id, null: false
|
|
9
8
|
t.string :provider, null: false
|
|
10
9
|
t.string :model, null: false
|
|
11
|
-
<% LlmCostTracker::TokenUsage.members.each do |column| -%>
|
|
10
|
+
<% LlmCostTracker::Usage::TokenUsage.members.each do |column| -%>
|
|
12
11
|
t.integer :<%= column %>, null: false, default: 0
|
|
13
12
|
<% end -%>
|
|
14
13
|
t.decimal :total_cost, precision: 20, scale: 8
|
|
@@ -21,7 +20,7 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
21
20
|
t.string :provider_workspace_id
|
|
22
21
|
t.boolean :batch, null: false, default: false
|
|
23
22
|
t.string :pricing_mode
|
|
24
|
-
t.string :cost_status, null: false, default: LlmCostTracker::
|
|
23
|
+
t.string :cost_status, null: false, default: LlmCostTracker::Charges::CostStatus::UNKNOWN
|
|
25
24
|
if postgresql?
|
|
26
25
|
t.jsonb :pricing_snapshot
|
|
27
26
|
elsif mysql?
|
|
@@ -50,7 +49,7 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
50
49
|
t.decimal :rate_quantity, precision: 30, scale: 10, null: false, default: 1
|
|
51
50
|
t.decimal :cost, precision: 20, scale: 8
|
|
52
51
|
t.string :currency, null: false, default: "USD"
|
|
53
|
-
t.string :cost_status, null: false, default: LlmCostTracker::
|
|
52
|
+
t.string :cost_status, null: false, default: LlmCostTracker::Charges::CostStatus::UNKNOWN
|
|
54
53
|
t.string :pricing_basis
|
|
55
54
|
t.string :price_key
|
|
56
55
|
t.string :price_source
|
|
@@ -57,8 +57,9 @@ LlmCostTracker.configure do |config|
|
|
|
57
57
|
# thread. Set to :async for a write-ahead inbox + background worker that batches
|
|
58
58
|
# inserts and survives caller transaction rollbacks. Requires the optional
|
|
59
59
|
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
|
|
60
|
-
#
|
|
61
|
-
#
|
|
60
|
+
# Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
|
|
61
|
+
# so they don't compete with request threads for the default pool when a tracked call
|
|
62
|
+
# happens inside an open caller transaction. Bump ingestion_pool_size if your Puma
|
|
62
63
|
# worker count outgrows that.
|
|
63
64
|
# config.ingestion = :async
|
|
64
65
|
# config.ingestion_pool_size = 5
|
|
@@ -22,31 +22,50 @@ module LlmCostTracker
|
|
|
22
22
|
rows.size
|
|
23
23
|
rescue StandardError => e
|
|
24
24
|
rows_to_mark = valid_rows&.any? ? valid_rows : rows
|
|
25
|
-
|
|
25
|
+
mark_failed_with_message(rows_to_mark, error_message_for(e)) if rows_to_mark&.any?
|
|
26
26
|
raise
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def pending?
|
|
30
|
-
Ingestion::InboxEntry.
|
|
30
|
+
Ingestion::InboxEntry.pending.exists?
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def claimable?
|
|
34
34
|
claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def
|
|
38
|
-
message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
|
|
37
|
+
def mark_failed_with_message(rows, message)
|
|
39
38
|
now = Time.now.utc
|
|
40
39
|
Ingestion::InboxEntry
|
|
41
40
|
.where(id: rows.map(&:id), locked_by: identity)
|
|
42
41
|
.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
|
|
42
|
+
warn_on_quarantine(rows)
|
|
43
43
|
rescue StandardError => e
|
|
44
44
|
LlmCostTracker::Logging.warn(
|
|
45
|
-
"Inbox
|
|
45
|
+
"Inbox mark_failed_with_message failed for #{rows.size} rows: #{e.class}: #{e.message} " \
|
|
46
|
+
"(attempted message: #{message.to_s.byteslice(0, 200)})"
|
|
46
47
|
)
|
|
47
48
|
nil
|
|
48
49
|
end
|
|
49
50
|
|
|
51
|
+
def error_message_for(error)
|
|
52
|
+
"#{error.class}: #{error.message}".byteslice(0, 1_000)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def warn_on_quarantine(rows)
|
|
56
|
+
threshold = Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE
|
|
57
|
+
quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
|
|
58
|
+
return if quarantined.empty?
|
|
59
|
+
|
|
60
|
+
sample = quarantined.first(10).map(&:id).join(", ")
|
|
61
|
+
sample += "..." if quarantined.size > 10
|
|
62
|
+
LlmCostTracker::Logging.warn(
|
|
63
|
+
"Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
|
|
64
|
+
"MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
|
|
65
|
+
"on the next claim cycle (ids: #{sample})"
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
50
69
|
private
|
|
51
70
|
|
|
52
71
|
attr_reader :identity
|
|
@@ -69,25 +88,37 @@ module LlmCostTracker
|
|
|
69
88
|
def decode(rows)
|
|
70
89
|
valid_rows = []
|
|
71
90
|
events = []
|
|
91
|
+
failures = Hash.new { |h, k| h[k] = [] }
|
|
72
92
|
rows.each do |row|
|
|
73
93
|
events << Ingestion::Inbox.event_from_row(row)
|
|
74
94
|
valid_rows << row
|
|
75
95
|
rescue StandardError => e
|
|
76
|
-
|
|
96
|
+
failures[error_message_for(e)] << row
|
|
77
97
|
end
|
|
98
|
+
failures.each { |message, failed_rows| mark_failed_with_message(failed_rows, message) }
|
|
78
99
|
[valid_rows, events]
|
|
79
100
|
end
|
|
80
101
|
|
|
81
|
-
def persist(rows, events)
|
|
102
|
+
def persist(rows, events, retry_on_conflict: true)
|
|
82
103
|
LlmCostTracker::Call.transaction do
|
|
83
104
|
Ledger::Store.insert(events)
|
|
84
105
|
Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
85
106
|
end
|
|
107
|
+
rescue ActiveRecord::RecordNotUnique
|
|
108
|
+
raise unless retry_on_conflict
|
|
109
|
+
|
|
110
|
+
already_persisted = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id)
|
|
111
|
+
fresh_events = events.reject { |event| already_persisted.include?(event.event_id) }
|
|
112
|
+
LlmCostTracker::Logging.warn(
|
|
113
|
+
"Ingestion::Batch#persist: #{already_persisted.size} event_id(s) already in ledger; " \
|
|
114
|
+
"skipped duplicates and persisted #{fresh_events.size} fresh event(s)"
|
|
115
|
+
)
|
|
116
|
+
persist(rows, fresh_events, retry_on_conflict: false)
|
|
86
117
|
end
|
|
87
118
|
|
|
88
119
|
def claimable_scope(cutoff)
|
|
89
120
|
Ingestion::InboxEntry
|
|
90
|
-
.
|
|
121
|
+
.pending
|
|
91
122
|
.where("locked_at IS NULL OR locked_at < ?", cutoff)
|
|
92
123
|
end
|
|
93
124
|
end
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
|
+
require "active_support/core_ext/hash/keys"
|
|
5
6
|
|
|
6
7
|
require_relative "../event"
|
|
7
8
|
require_relative "../pricing"
|
|
8
|
-
require_relative "../billing/line_item"
|
|
9
9
|
|
|
10
10
|
module LlmCostTracker
|
|
11
11
|
module Ingestion
|
|
12
|
-
|
|
12
|
+
module Inbox
|
|
13
13
|
PAYLOAD_SCHEMA_VERSION = 2
|
|
14
14
|
|
|
15
15
|
class << self
|
|
@@ -30,8 +30,8 @@ module LlmCostTracker
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
32
|
def event_attributes_from(payload)
|
|
33
|
-
cost = payload[:cost] &&
|
|
34
|
-
token_usage = TokenUsage.build(**payload.fetch(:token_usage).slice(*TokenUsage.members))
|
|
33
|
+
cost = payload[:cost] && Charges::Cost.from_h(payload[:cost])
|
|
34
|
+
token_usage = Usage::TokenUsage.build(**payload.fetch(:token_usage).slice(*Usage::TokenUsage.members))
|
|
35
35
|
|
|
36
36
|
{
|
|
37
37
|
event_id: payload.fetch(:event_id),
|
|
@@ -43,16 +43,15 @@ module LlmCostTracker
|
|
|
43
43
|
tags: payload.fetch(:tags),
|
|
44
44
|
latency_ms: payload[:latency_ms],
|
|
45
45
|
stream: payload.fetch(:stream),
|
|
46
|
-
usage_source: payload[:usage_source]
|
|
46
|
+
usage_source: payload[:usage_source],
|
|
47
47
|
provider_response_id: payload[:provider_response_id],
|
|
48
48
|
provider_project_id: payload[:provider_project_id],
|
|
49
49
|
provider_api_key_id: payload[:provider_api_key_id],
|
|
50
50
|
provider_workspace_id: payload[:provider_workspace_id],
|
|
51
|
-
batch: payload.fetch(:batch),
|
|
52
51
|
tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
|
|
53
52
|
cost_status: payload.fetch(:cost_status),
|
|
54
|
-
pricing_snapshot: payload[:pricing_snapshot],
|
|
55
|
-
line_items: (payload[:line_items] || []).map { |attrs|
|
|
53
|
+
pricing_snapshot: payload[:pricing_snapshot]&.deep_stringify_keys,
|
|
54
|
+
line_items: (payload[:line_items] || []).map { |attrs| Charges::LineItem.build(attrs) }
|
|
56
55
|
}
|
|
57
56
|
end
|
|
58
57
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/module/delegation"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Ingestion
|
|
5
7
|
module Pool
|
|
@@ -7,22 +9,12 @@ module LlmCostTracker
|
|
|
7
9
|
MUTEX = Mutex.new
|
|
8
10
|
|
|
9
11
|
class << self
|
|
10
|
-
|
|
11
|
-
pool.with_connection(&)
|
|
12
|
-
end
|
|
12
|
+
delegate :with_connection, to: :pool
|
|
13
13
|
|
|
14
14
|
def pool
|
|
15
15
|
@pool || MUTEX.synchronize { @pool ||= connect! }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def reset!
|
|
19
|
-
MUTEX.synchronize do
|
|
20
|
-
@pool&.disconnect!
|
|
21
|
-
@pool = nil
|
|
22
|
-
@handler = nil
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
18
|
private
|
|
27
19
|
|
|
28
20
|
def connect!
|
|
@@ -6,11 +6,10 @@ require "securerandom"
|
|
|
6
6
|
require_relative "inbox"
|
|
7
7
|
require_relative "batch"
|
|
8
8
|
require_relative "lease_claim"
|
|
9
|
-
require_relative "../logging"
|
|
10
9
|
|
|
11
10
|
module LlmCostTracker
|
|
12
11
|
module Ingestion
|
|
13
|
-
|
|
12
|
+
module Worker
|
|
14
13
|
INTERVAL_SECONDS = 0.25
|
|
15
14
|
IDLE_INTERVAL_SECONDS = 1.0
|
|
16
15
|
MAX_IDLE_INTERVAL_SECONDS = 5.0
|
|
@@ -66,8 +65,12 @@ module LlmCostTracker
|
|
|
66
65
|
@generation = @generation.to_i + 1
|
|
67
66
|
@thread
|
|
68
67
|
end
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
begin
|
|
69
|
+
wake_thread(thread)
|
|
70
|
+
thread&.join(timeout)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
handle_error(e)
|
|
73
|
+
end
|
|
71
74
|
drain ? flush!(timeout: timeout, require_lease: true) : true
|
|
72
75
|
rescue StandardError => e
|
|
73
76
|
handle_error(e)
|
|
@@ -78,19 +81,6 @@ module LlmCostTracker
|
|
|
78
81
|
end
|
|
79
82
|
end
|
|
80
83
|
|
|
81
|
-
def reset!
|
|
82
|
-
thread = MUTEX.synchronize do
|
|
83
|
-
@stop_requested = false
|
|
84
|
-
@generation = @generation.to_i + 1
|
|
85
|
-
thread = @thread
|
|
86
|
-
@thread = nil
|
|
87
|
-
@pid = nil
|
|
88
|
-
@identity = nil
|
|
89
|
-
thread
|
|
90
|
-
end
|
|
91
|
-
wake_thread(thread)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
84
|
def flush_timeout_seconds(timeout)
|
|
95
85
|
numeric = Float(timeout, exception: false)
|
|
96
86
|
return FLUSH_TIMEOUT_SECONDS unless numeric&.finite? && numeric.positive?
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/notifications"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
|
|
5
6
|
require_relative "errors"
|
|
7
|
+
require_relative "check"
|
|
6
8
|
require_relative "ledger"
|
|
7
|
-
require_relative "ingestion/lease_claim"
|
|
8
|
-
require_relative "ingestion/pool"
|
|
9
|
-
require_relative "ingestion/inbox"
|
|
10
|
-
require_relative "ingestion/batch"
|
|
11
|
-
require_relative "ingestion/worker"
|
|
12
9
|
|
|
13
10
|
module LlmCostTracker
|
|
14
11
|
module Ingestion
|
|
12
|
+
autoload :LeaseClaim, "llm_cost_tracker/ingestion/lease_claim"
|
|
13
|
+
autoload :Pool, "llm_cost_tracker/ingestion/pool"
|
|
14
|
+
autoload :Inbox, "llm_cost_tracker/ingestion/inbox"
|
|
15
|
+
autoload :Batch, "llm_cost_tracker/ingestion/batch"
|
|
16
|
+
autoload :Worker, "llm_cost_tracker/ingestion/worker"
|
|
17
|
+
|
|
15
18
|
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
16
19
|
|
|
17
20
|
class << self
|
|
@@ -19,25 +22,12 @@ module LlmCostTracker
|
|
|
19
22
|
"llm_cost_tracker_ingestion_"
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
CORE_SCHEMA_GUARDS = [
|
|
23
|
-
["llm_cost_tracker_calls", Ledger::Schema::Calls],
|
|
24
|
-
["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
|
|
25
|
-
["llm_cost_tracker_call_tags", Ledger::Schema::CallTags]
|
|
26
|
-
].freeze
|
|
27
|
-
|
|
28
|
-
ROLLUPS_SCHEMA_GUARD = ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups].freeze
|
|
29
|
-
|
|
30
|
-
ASYNC_SCHEMA_GUARDS = [
|
|
31
|
-
["llm_cost_tracker_ingestion_inbox_entries", Ledger::Schema::IngestionInboxEntries],
|
|
32
|
-
["llm_cost_tracker_ingestion_leases", Ledger::Schema::IngestionLeases]
|
|
33
|
-
].freeze
|
|
34
|
-
|
|
35
25
|
def ensure_current_schema!
|
|
36
26
|
unless LlmCostTracker::Call.table_exists?
|
|
37
27
|
raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
38
28
|
end
|
|
39
29
|
|
|
40
|
-
guards_for_current_config.each do |
|
|
30
|
+
guards_for_current_config.each do |schema_module, table_name|
|
|
41
31
|
errors = schema_module.current_schema_errors
|
|
42
32
|
next if errors.empty?
|
|
43
33
|
|
|
@@ -55,16 +45,16 @@ module LlmCostTracker
|
|
|
55
45
|
end
|
|
56
46
|
|
|
57
47
|
def guards_for_current_config
|
|
58
|
-
guards =
|
|
59
|
-
guards <<
|
|
60
|
-
guards +=
|
|
48
|
+
guards = Ledger::Schema::CORE_SCHEMAS.dup
|
|
49
|
+
guards << Ledger::Schema::CACHE_ROLLUPS_SCHEMA if cache_rollups?
|
|
50
|
+
guards += Ledger::Schema::ASYNC_SCHEMAS if async?
|
|
61
51
|
guards
|
|
62
52
|
end
|
|
63
53
|
|
|
64
54
|
def verify
|
|
65
55
|
unless LlmCostTracker::Call.table_exists?
|
|
66
56
|
return [
|
|
67
|
-
LlmCostTracker::
|
|
57
|
+
LlmCostTracker::Check.new(
|
|
68
58
|
:error,
|
|
69
59
|
"active_record",
|
|
70
60
|
"llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
@@ -74,7 +64,7 @@ module LlmCostTracker
|
|
|
74
64
|
|
|
75
65
|
[capture_check]
|
|
76
66
|
rescue StandardError => e
|
|
77
|
-
[LlmCostTracker::
|
|
67
|
+
[LlmCostTracker::Check.new(:error, "active_record", "#{e.class}: #{e.message}")]
|
|
78
68
|
end
|
|
79
69
|
|
|
80
70
|
private
|
|
@@ -88,7 +78,7 @@ module LlmCostTracker
|
|
|
88
78
|
event = LlmCostTracker.track(
|
|
89
79
|
provider: provider,
|
|
90
80
|
model: model,
|
|
91
|
-
tokens: {
|
|
81
|
+
tokens: { input_tokens: 1, output_tokens: 1 },
|
|
92
82
|
provider_response_id: response_id,
|
|
93
83
|
tags: { feature: VERIFY_TAG }
|
|
94
84
|
)
|
|
@@ -97,17 +87,17 @@ module LlmCostTracker
|
|
|
97
87
|
|
|
98
88
|
return capture_success if persisted && notifications.any?
|
|
99
89
|
|
|
100
|
-
LlmCostTracker::
|
|
90
|
+
LlmCostTracker::Check.new(
|
|
101
91
|
:error,
|
|
102
92
|
"active_record capture",
|
|
103
93
|
capture_failure_message(persisted, notifications)
|
|
104
94
|
)
|
|
105
95
|
rescue LlmCostTracker::BudgetExceededError => e
|
|
106
|
-
LlmCostTracker::
|
|
96
|
+
LlmCostTracker::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
|
|
107
97
|
rescue LlmCostTracker::Error => e
|
|
108
|
-
LlmCostTracker::
|
|
98
|
+
LlmCostTracker::Check.new(:error, "active_record capture", e.message)
|
|
109
99
|
rescue StandardError => e
|
|
110
|
-
LlmCostTracker::
|
|
100
|
+
LlmCostTracker::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
|
|
111
101
|
ensure
|
|
112
102
|
cleanup_verification_call(response_id) if response_id
|
|
113
103
|
cleanup_verification_inbox(event: event, response_id: response_id)
|
|
@@ -122,7 +112,7 @@ module LlmCostTracker
|
|
|
122
112
|
|
|
123
113
|
def capture_success
|
|
124
114
|
path = async? ? "async inbox" : "inline writer"
|
|
125
|
-
LlmCostTracker::
|
|
115
|
+
LlmCostTracker::Check.new(
|
|
126
116
|
:ok,
|
|
127
117
|
"active_record capture",
|
|
128
118
|
"manual event emitted and persisted through #{path}"
|
|
@@ -138,13 +128,11 @@ module LlmCostTracker
|
|
|
138
128
|
|
|
139
129
|
def cleanup_verification_call(response_id)
|
|
140
130
|
relation = LlmCostTracker::Call.where(provider_response_id: response_id)
|
|
141
|
-
|
|
142
|
-
return if
|
|
131
|
+
records = relation.select(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider).to_a
|
|
132
|
+
return if records.empty?
|
|
143
133
|
|
|
144
134
|
relation.delete_all
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
LlmCostTracker::Ledger::Rollups.decrement!(rows)
|
|
135
|
+
LlmCostTracker::Ledger::Rollups.decrement!(records) if cache_rollups?
|
|
148
136
|
end
|
|
149
137
|
|
|
150
138
|
def cleanup_verification_inbox(event:, response_id:)
|
|
@@ -162,7 +150,7 @@ module LlmCostTracker
|
|
|
162
150
|
|
|
163
151
|
def sample_priced_identity
|
|
164
152
|
key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
|
|
165
|
-
model_id.include?("/") && prices[
|
|
153
|
+
model_id.include?("/") && prices["input"] && prices["output"]
|
|
166
154
|
end&.first
|
|
167
155
|
provider, model = key.to_s.split("/", 2)
|
|
168
156
|
[provider || "openai", model || "gpt-4o-mini"]
|