llm_cost_tracker 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- 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/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- 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/reconciliation/index.html.erb +49 -58
- 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 +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- 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.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- 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 +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- 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 +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- 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/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- 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/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "pricing/mode"
|
|
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::Mode.normalize(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
|
|
@@ -35,4 +78,8 @@ module LlmCostTracker
|
|
|
35
78
|
)
|
|
36
79
|
end
|
|
37
80
|
end
|
|
81
|
+
|
|
82
|
+
class Event
|
|
83
|
+
UNKNOWN_MODEL = "unknown"
|
|
84
|
+
end
|
|
38
85
|
end
|
|
@@ -5,18 +5,18 @@ require "rails/generators/active_record"
|
|
|
5
5
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
module Generators
|
|
8
|
-
class
|
|
8
|
+
class AsyncIngestionGenerator < Rails::Generators::Base
|
|
9
9
|
include ActiveRecord::Generators::Migration
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "Creates the
|
|
14
|
-
"Required when config.
|
|
13
|
+
desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
|
|
14
|
+
"Required when config.ingestion = :async."
|
|
15
15
|
|
|
16
16
|
def create_migration_file
|
|
17
17
|
migration_template(
|
|
18
|
-
"
|
|
19
|
-
"db/migrate/
|
|
18
|
+
"create_llm_cost_tracker_async_ingestion.rb.erb",
|
|
19
|
+
"db/migrate/create_llm_cost_tracker_async_ingestion.rb"
|
|
20
20
|
)
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -25,11 +25,11 @@ module LlmCostTracker
|
|
|
25
25
|
After migrating, set the following in config/initializers/llm_cost_tracker.rb:
|
|
26
26
|
|
|
27
27
|
LlmCostTracker.configure do |config|
|
|
28
|
-
config.
|
|
28
|
+
config.ingestion = :async
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
Without it the
|
|
32
|
-
inline. The doctor check warns about unused
|
|
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
33
|
MSG
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -26,10 +26,10 @@ 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
|
|
@@ -42,7 +42,6 @@ module LlmCostTracker
|
|
|
42
42
|
def mount_engine
|
|
43
43
|
return unless options[:dashboard]
|
|
44
44
|
|
|
45
|
-
add_engine_require
|
|
46
45
|
say(<<~MSG, :yellow)
|
|
47
46
|
The LLM Cost Tracker dashboard ships without authentication.
|
|
48
47
|
Mount it in config/routes.rb behind your app's admin auth, e.g.:
|
|
@@ -61,24 +60,6 @@ module LlmCostTracker
|
|
|
61
60
|
def migration_version
|
|
62
61
|
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
63
62
|
end
|
|
64
|
-
|
|
65
|
-
def add_engine_require
|
|
66
|
-
return unless File.exist?("config/application.rb")
|
|
67
|
-
|
|
68
|
-
contents = File.read("config/application.rb")
|
|
69
|
-
return if contents.include?(%(require "llm_cost_tracker/engine"))
|
|
70
|
-
|
|
71
|
-
unless contents.include?(%(require "rails/all"\n))
|
|
72
|
-
prepend_to_file("config/application.rb", %(require "llm_cost_tracker/engine"\n))
|
|
73
|
-
return
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
inject_into_file(
|
|
77
|
-
"config/application.rb",
|
|
78
|
-
%(require "llm_cost_tracker/engine"\n),
|
|
79
|
-
after: %(require "rails/all"\n)
|
|
80
|
-
)
|
|
81
|
-
end
|
|
82
63
|
end
|
|
83
64
|
end
|
|
84
65
|
end
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
class
|
|
1
|
+
class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_version %>
|
|
2
2
|
def change
|
|
3
3
|
create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
|
|
4
4
|
t.string :event_id, null: false
|
|
5
5
|
t.decimal :total_cost, precision: 20, scale: 8
|
|
6
6
|
t.datetime :tracked_at, null: false
|
|
7
|
-
t.text :payload, null: false
|
|
7
|
+
t.text :payload, null: false, limit: 16.megabytes
|
|
8
8
|
t.datetime :locked_at
|
|
9
9
|
t.string :locked_by
|
|
10
10
|
t.integer :attempts, null: false, default: 0
|
|
11
|
-
t.text :last_error
|
|
11
|
+
t.text :last_error, limit: 16.megabytes
|
|
12
12
|
|
|
13
13
|
t.timestamps
|
|
14
14
|
end
|
|
@@ -23,6 +23,7 @@ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_
|
|
|
23
23
|
|
|
24
24
|
create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
|
|
25
25
|
t.string :source, null: false
|
|
26
|
+
t.string :provider, null: false, default: ""
|
|
26
27
|
t.string :cursor
|
|
27
28
|
t.date :window_start
|
|
28
29
|
t.date :window_end
|
|
@@ -39,7 +40,11 @@ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_
|
|
|
39
40
|
if_not_exists: true
|
|
40
41
|
add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
|
|
41
42
|
if_not_exists: true
|
|
42
|
-
|
|
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],
|
|
43
48
|
if_not_exists: true
|
|
44
49
|
end
|
|
45
50
|
|
|
@@ -22,7 +22,9 @@ LlmCostTracker.configure do |config|
|
|
|
22
22
|
# config.instrument :ruby_llm
|
|
23
23
|
|
|
24
24
|
# Pricing — local file refreshed via bin/rails llm_cost_tracker:prices:refresh
|
|
25
|
-
# plus inline overrides.
|
|
25
|
+
# plus inline overrides. Rates are per 1M tokens; the snapshot's currency
|
|
26
|
+
# is read from your prices_file's `metadata.currency` (USD in the bundled
|
|
27
|
+
# snapshot — set a different code per file if you maintain non-USD prices).
|
|
26
28
|
<% if options[:prices] -%>
|
|
27
29
|
config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
28
30
|
<% else -%>
|
|
@@ -37,8 +39,9 @@ LlmCostTracker.configure do |config|
|
|
|
37
39
|
|
|
38
40
|
# Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
|
|
39
41
|
# plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
|
|
40
|
-
# raises after recording; :block_requests preflights supported requests
|
|
41
|
-
#
|
|
42
|
+
# raises after recording; :block_requests preflights supported requests, also
|
|
43
|
+
# estimating the current call's input cost via chars/4 so it can block before
|
|
44
|
+
# send) and an optional callback. Cap evaluation reads from llm_cost_tracker_calls live;
|
|
42
45
|
# flip cache_rollups to true at high volume so reads hit the rollups table
|
|
43
46
|
# instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
44
47
|
# config.monthly_budget = 100.00
|
|
@@ -50,11 +53,15 @@ LlmCostTracker.configure do |config|
|
|
|
50
53
|
# }
|
|
51
54
|
# config.cache_rollups = true
|
|
52
55
|
|
|
53
|
-
# Ingestion path —
|
|
54
|
-
# thread.
|
|
56
|
+
# Ingestion path — :inline (default) writes events synchronously from the request
|
|
57
|
+
# thread. Set to :async for a write-ahead inbox + background worker that batches
|
|
55
58
|
# inserts and survives caller transaction rollbacks. Requires the optional
|
|
56
|
-
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:
|
|
57
|
-
#
|
|
59
|
+
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
|
|
60
|
+
# The worker uses a dedicated ActiveRecord pool (defaults to 2 connections) so it
|
|
61
|
+
# doesn't compete with request threads — bump ingestion_pool_size if your Puma
|
|
62
|
+
# worker count outgrows that.
|
|
63
|
+
# config.ingestion = :async
|
|
64
|
+
# config.ingestion_pool_size = 5
|
|
58
65
|
|
|
59
66
|
# Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
|
|
60
67
|
# for bin/rails llm_cost_tracker:report.
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
CHANGED
|
@@ -1,20 +1,39 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
1
3
|
class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
2
6
|
TABLE = :llm_cost_tracker_call_rollups
|
|
3
7
|
OLD_INDEX = %i[period period_start currency].freeze
|
|
4
8
|
NEW_INDEX = %i[period period_start currency provider].freeze
|
|
5
9
|
|
|
6
10
|
def up
|
|
7
|
-
unless
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
remove_index TABLE, column: OLD_INDEX, unique: true
|
|
12
|
-
add_index TABLE, NEW_INDEX, unique: true unless index_exists?(TABLE, NEW_INDEX, unique: true)
|
|
11
|
+
return unless table_exists?(TABLE)
|
|
12
|
+
|
|
13
|
+
add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
|
|
14
|
+
add_unique_index NEW_INDEX
|
|
15
|
+
remove_index TABLE, column: OLD_INDEX, unique: true, if_exists: true
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def down
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
return unless table_exists?(TABLE)
|
|
20
|
+
|
|
21
|
+
add_unique_index OLD_INDEX
|
|
22
|
+
remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
|
|
18
23
|
remove_column TABLE, :provider if column_exists?(TABLE, :provider)
|
|
19
24
|
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def add_unique_index(columns)
|
|
29
|
+
if postgresql?
|
|
30
|
+
add_index TABLE, columns, unique: true, algorithm: :concurrently, if_not_exists: true
|
|
31
|
+
else
|
|
32
|
+
add_index TABLE, columns, unique: true, if_not_exists: true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def postgresql?
|
|
37
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
38
|
+
end
|
|
20
39
|
end
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
2
|
|
|
3
3
|
class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
4
6
|
TABLE = :llm_cost_tracker_call_tags
|
|
5
7
|
INDEX_COLUMNS = %i[key value].freeze
|
|
6
8
|
|
|
7
9
|
def up
|
|
8
|
-
return if index_exists?(TABLE, INDEX_COLUMNS)
|
|
9
|
-
|
|
10
10
|
if postgresql?
|
|
11
|
-
add_index TABLE, INDEX_COLUMNS
|
|
11
|
+
add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
|
|
12
12
|
elsif mysql?
|
|
13
|
-
add_index TABLE, INDEX_COLUMNS, length: { value: 191 }
|
|
13
|
+
add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
|
|
14
14
|
else
|
|
15
15
|
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def down
|
|
20
|
-
remove_index TABLE, column: INDEX_COLUMNS
|
|
20
|
+
remove_index TABLE, column: INDEX_COLUMNS, if_exists: true
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerProviderInvoiceImportsProvider < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
TABLE = :llm_cost_tracker_provider_invoice_imports
|
|
7
|
+
OLD_INDEX = %i[source started_at].freeze
|
|
8
|
+
NEW_INDEX = %i[source provider started_at].freeze
|
|
9
|
+
|
|
10
|
+
def up
|
|
11
|
+
return unless table_exists?(TABLE)
|
|
12
|
+
|
|
13
|
+
add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
|
|
14
|
+
if postgresql?
|
|
15
|
+
remove_index TABLE, column: OLD_INDEX, algorithm: :concurrently, if_exists: true
|
|
16
|
+
add_index TABLE, NEW_INDEX, algorithm: :concurrently, if_not_exists: true
|
|
17
|
+
else
|
|
18
|
+
remove_index TABLE, column: OLD_INDEX, if_exists: true
|
|
19
|
+
add_index TABLE, NEW_INDEX, if_not_exists: true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def down
|
|
24
|
+
return unless table_exists?(TABLE)
|
|
25
|
+
|
|
26
|
+
remove_index TABLE, column: NEW_INDEX, if_exists: true
|
|
27
|
+
add_index TABLE, OLD_INDEX, if_not_exists: true
|
|
28
|
+
remove_column TABLE, :provider if column_exists?(TABLE, :provider)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def postgresql?
|
|
34
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerProviderInvoicesMetadataIndex < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
TABLE = :llm_cost_tracker_provider_invoices
|
|
7
|
+
|
|
8
|
+
def up
|
|
9
|
+
return unless postgresql?
|
|
10
|
+
return unless table_exists?(TABLE)
|
|
11
|
+
|
|
12
|
+
add_index TABLE, :metadata, using: :gin, algorithm: :concurrently, if_not_exists: true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def down
|
|
16
|
+
return unless postgresql?
|
|
17
|
+
return unless table_exists?(TABLE)
|
|
18
|
+
|
|
19
|
+
remove_index TABLE, column: :metadata, if_exists: true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def postgresql?
|
|
25
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb
CHANGED
|
@@ -19,15 +19,6 @@ module LlmCostTracker
|
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def warn_about_rollups_truncation
|
|
23
|
-
say(<<~MSG, :yellow)
|
|
24
|
-
The migration clears existing llm_cost_tracker_call_rollups rows before adding the
|
|
25
|
-
provider column. Budget reads fall back to live aggregation from
|
|
26
|
-
llm_cost_tracker_calls until new events repopulate the rollups under their provider
|
|
27
|
-
keys. See docs/upgrading.md for details.
|
|
28
|
-
MSG
|
|
29
|
-
end
|
|
30
|
-
|
|
31
22
|
private
|
|
32
23
|
|
|
33
24
|
def migration_version
|
|
@@ -0,0 +1,31 @@
|
|
|
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 UpgradeProviderInvoiceImportsProviderGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds a provider column to llm_cost_tracker_provider_invoice_imports and a " \
|
|
14
|
+
"(source, provider, started_at) index so resume_cursor_for and " \
|
|
15
|
+
"last_completed_window_for can isolate per-provider state on shared sources (e.g. csv)."
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template(
|
|
19
|
+
"upgrade_provider_invoice_imports_provider.rb.erb",
|
|
20
|
+
"db/migrate/upgrade_llm_cost_tracker_provider_invoice_imports_provider.rb"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def migration_version
|
|
27
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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 UpgradeProviderInvoicesMetadataIndexGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds a GIN index on llm_cost_tracker_provider_invoices.metadata for PostgreSQL " \
|
|
14
|
+
"so Reconciliation::Diff queries that filter on metadata->>'provider' / 'row_type' / " \
|
|
15
|
+
"'match_basis' hit an index instead of a sequential scan. No-op on MySQL."
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template(
|
|
19
|
+
"upgrade_provider_invoices_metadata_index.rb.erb",
|
|
20
|
+
"db/migrate/upgrade_llm_cost_tracker_provider_invoices_metadata_index.rb"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def migration_version
|
|
27
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -40,7 +40,10 @@ module LlmCostTracker
|
|
|
40
40
|
Ingestion::InboxEntry
|
|
41
41
|
.where(id: rows.map(&:id), locked_by: identity)
|
|
42
42
|
.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
|
|
43
|
-
rescue StandardError
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
LlmCostTracker::Logging.warn(
|
|
45
|
+
"Inbox mark_failed failed for #{rows.size} rows: #{e.class}: #{e.message} (original error: #{error.class})"
|
|
46
|
+
)
|
|
44
47
|
nil
|
|
45
48
|
end
|
|
46
49
|
|
|
@@ -77,7 +80,7 @@ module LlmCostTracker
|
|
|
77
80
|
|
|
78
81
|
def persist(rows, events)
|
|
79
82
|
LlmCostTracker::Call.transaction do
|
|
80
|
-
Ledger::Store.
|
|
83
|
+
Ledger::Store.insert(events)
|
|
81
84
|
Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
82
85
|
end
|
|
83
86
|
end
|
|
@@ -15,8 +15,6 @@ module LlmCostTracker
|
|
|
15
15
|
class << self
|
|
16
16
|
def save(event)
|
|
17
17
|
insert_row(row_for(event))
|
|
18
|
-
Ingestion::Worker.ensure_started
|
|
19
|
-
event
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def event_from_row(row)
|
|
@@ -40,7 +38,7 @@ module LlmCostTracker
|
|
|
40
38
|
provider: payload.fetch(:provider),
|
|
41
39
|
model: payload.fetch(:model),
|
|
42
40
|
token_usage: token_usage,
|
|
43
|
-
pricing_mode: Pricing.
|
|
41
|
+
pricing_mode: Pricing::Mode.normalize(payload[:pricing_mode]),
|
|
44
42
|
cost: cost,
|
|
45
43
|
tags: payload.fetch(:tags),
|
|
46
44
|
latency_ms: payload[:latency_ms],
|
|
@@ -54,14 +52,10 @@ module LlmCostTracker
|
|
|
54
52
|
tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
|
|
55
53
|
cost_status: payload.fetch(:cost_status),
|
|
56
54
|
pricing_snapshot: payload[:pricing_snapshot],
|
|
57
|
-
line_items:
|
|
55
|
+
line_items: (payload[:line_items] || []).map { |attrs| Billing::LineItem.build(attrs) }
|
|
58
56
|
}
|
|
59
57
|
end
|
|
60
58
|
|
|
61
|
-
def line_items_from(payload)
|
|
62
|
-
(payload[:line_items] || []).map { |attributes| Billing::LineItem.build(attributes) }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
59
|
def row_for(event)
|
|
66
60
|
now = Time.now.utc
|
|
67
61
|
{
|
|
@@ -86,25 +80,10 @@ module LlmCostTracker
|
|
|
86
80
|
end
|
|
87
81
|
|
|
88
82
|
def insert_row(row)
|
|
89
|
-
connection
|
|
90
|
-
if connection.transaction_open?
|
|
91
|
-
insert_with_separate_connection(row)
|
|
92
|
-
else
|
|
93
|
-
execute_insert(connection, row)
|
|
94
|
-
end
|
|
83
|
+
Pool.with_connection { |connection| execute_insert(connection, row) }
|
|
95
84
|
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
96
85
|
raise LlmCostTracker::Error,
|
|
97
|
-
"ledger inbox could not checkout a
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def insert_with_separate_connection(row)
|
|
101
|
-
pool = LlmCostTracker::Call.connection_pool
|
|
102
|
-
connection = pool.checkout
|
|
103
|
-
begin
|
|
104
|
-
connection.transaction(requires_new: true) { execute_insert(connection, row) }
|
|
105
|
-
ensure
|
|
106
|
-
pool.checkin(connection)
|
|
107
|
-
end
|
|
86
|
+
"ledger inbox could not checkout a database connection: #{e.message}"
|
|
108
87
|
end
|
|
109
88
|
|
|
110
89
|
def execute_insert(connection, row)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Ingestion
|
|
5
|
+
module Pool
|
|
6
|
+
DEFAULT_POOL_SIZE = 2
|
|
7
|
+
MUTEX = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def with_connection(&)
|
|
11
|
+
pool.with_connection(&)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pool
|
|
15
|
+
@pool || MUTEX.synchronize { @pool ||= connect! }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reset!
|
|
19
|
+
MUTEX.synchronize do
|
|
20
|
+
@pool&.disconnect!
|
|
21
|
+
@pool = nil
|
|
22
|
+
@handler = nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def connect!
|
|
29
|
+
@handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
|
|
30
|
+
@handler.establish_connection(connection_config)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def connection_config
|
|
34
|
+
LlmCostTracker::Call.connection_db_config.configuration_hash.merge(pool: pool_size)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pool_size
|
|
38
|
+
configured = LlmCostTracker.configuration.ingestion_pool_size.to_i
|
|
39
|
+
configured.positive? ? configured : DEFAULT_POOL_SIZE
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|