llm_cost_tracker 0.7.0 → 0.7.2
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 +31 -0
- data/README.md +21 -16
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/lib/llm_cost_tracker/budget.rb +8 -20
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +33 -36
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +63 -71
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
- data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
- data/lib/llm_cost_tracker/parsers/base.rb +12 -21
- data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
- data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +361 -36
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
- data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -8
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -8
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +39 -69
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -78
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +54 -58
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -67
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/writer.rb +0 -35
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -105
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
class UpgradeLlmApiCallCostPrecision < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
-
COST_COLUMNS = %i[
|
|
2
|
+
COST_COLUMNS = %i[
|
|
3
|
+
input_cost
|
|
4
|
+
cache_read_input_cost
|
|
5
|
+
cache_write_input_cost
|
|
6
|
+
cache_write_1h_input_cost
|
|
7
|
+
output_cost
|
|
8
|
+
total_cost
|
|
9
|
+
].freeze
|
|
3
10
|
|
|
4
11
|
def up
|
|
5
12
|
COST_COLUMNS.each do |column|
|
|
13
|
+
next unless column_exists?(:llm_api_calls, column)
|
|
14
|
+
|
|
6
15
|
change_column :llm_api_calls, column, :decimal, precision: 20, scale: 8
|
|
7
16
|
end
|
|
8
17
|
end
|
|
9
18
|
|
|
10
19
|
def down
|
|
11
20
|
COST_COLUMNS.each do |column|
|
|
21
|
+
next unless column_exists?(:llm_api_calls, column)
|
|
22
|
+
|
|
12
23
|
change_column :llm_api_calls, column, :decimal, precision: 12, scale: 8
|
|
13
24
|
end
|
|
14
25
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require "llm_cost_tracker/
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
2
|
|
|
3
3
|
class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
|
|
4
4
|
def up
|
|
@@ -34,7 +34,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
36
|
def postgresql?
|
|
37
|
-
LlmCostTracker::
|
|
37
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def tags_jsonb?
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "active_record_store"
|
|
3
|
+
require_relative "inbox"
|
|
4
|
+
require_relative "../ledger/store"
|
|
6
5
|
|
|
7
6
|
module LlmCostTracker
|
|
8
|
-
module
|
|
9
|
-
class
|
|
7
|
+
module Ingestion
|
|
8
|
+
class Batch
|
|
10
9
|
BATCH_SIZE = 100
|
|
11
10
|
LOCK_TIMEOUT_SECONDS = 30
|
|
12
11
|
|
|
@@ -27,14 +26,18 @@ module LlmCostTracker
|
|
|
27
26
|
raise
|
|
28
27
|
end
|
|
29
28
|
|
|
30
|
-
def pending?
|
|
29
|
+
def pending?
|
|
30
|
+
Ingestion::Event.where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS).exists?
|
|
31
|
+
end
|
|
31
32
|
|
|
32
|
-
def claimable?
|
|
33
|
+
def claimable?
|
|
34
|
+
claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
|
|
35
|
+
end
|
|
33
36
|
|
|
34
37
|
def mark_failed(rows, error)
|
|
35
38
|
message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
|
|
36
39
|
now = Time.now.utc
|
|
37
|
-
|
|
40
|
+
Ingestion::Event
|
|
38
41
|
.where(id: rows.map(&:id), locked_by: identity)
|
|
39
42
|
.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
|
|
40
43
|
rescue StandardError
|
|
@@ -48,16 +51,16 @@ module LlmCostTracker
|
|
|
48
51
|
def claim
|
|
49
52
|
now = Time.now.utc
|
|
50
53
|
cutoff = now - LOCK_TIMEOUT_SECONDS
|
|
51
|
-
|
|
54
|
+
Ingestion::Event.transaction do
|
|
52
55
|
rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
|
|
53
56
|
ids = rows.map(&:id)
|
|
54
57
|
next [] if ids.empty?
|
|
55
58
|
|
|
56
|
-
updates =
|
|
59
|
+
updates = Ingestion::Event.sanitize_sql_array(
|
|
57
60
|
["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
|
|
58
61
|
)
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
Ingestion::Event.where(id: ids).update_all(updates)
|
|
63
|
+
Ingestion::Event.where(id: ids, locked_by: identity).order(:id).to_a
|
|
61
64
|
end
|
|
62
65
|
end
|
|
63
66
|
|
|
@@ -65,7 +68,7 @@ module LlmCostTracker
|
|
|
65
68
|
valid_rows = []
|
|
66
69
|
events = []
|
|
67
70
|
rows.each do |row|
|
|
68
|
-
events <<
|
|
71
|
+
events << Ingestion::Inbox.event_from_row(row)
|
|
69
72
|
valid_rows << row
|
|
70
73
|
rescue StandardError => e
|
|
71
74
|
mark_failed([row], e)
|
|
@@ -74,19 +77,17 @@ module LlmCostTracker
|
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
def persist(rows, events)
|
|
77
|
-
LlmCostTracker::
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
LlmCostTracker::Ledger::Call.transaction do
|
|
81
|
+
Ledger::Store.insert_many(events)
|
|
82
|
+
Ingestion::Event.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
80
83
|
end
|
|
81
84
|
end
|
|
82
85
|
|
|
83
86
|
def claimable_scope(cutoff)
|
|
84
|
-
|
|
85
|
-
.where("attempts < ?",
|
|
87
|
+
Ingestion::Event
|
|
88
|
+
.where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS)
|
|
86
89
|
.where("locked_at IS NULL OR locked_at < ?", cutoff)
|
|
87
90
|
end
|
|
88
|
-
|
|
89
|
-
def model = LlmCostTracker::InboxEvent
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "../event"
|
|
7
|
+
require_relative "../pricing"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
module Ingestion
|
|
11
|
+
class Inbox
|
|
12
|
+
PAYLOAD_SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def save(event)
|
|
16
|
+
insert_row(row_for(event))
|
|
17
|
+
Ingestion::Worker.ensure_started
|
|
18
|
+
event
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def event_from_row(row)
|
|
22
|
+
payload = JSON.parse(row.payload)
|
|
23
|
+
schema_version = payload.fetch("schema_version", 0)
|
|
24
|
+
unless [0, PAYLOAD_SCHEMA_VERSION].include?(schema_version)
|
|
25
|
+
raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
cost = payload["cost"] && Pricing.stored_cost_attributes(payload["cost"])
|
|
29
|
+
token_usage = payload["token_usage"] || payload
|
|
30
|
+
|
|
31
|
+
LlmCostTracker::Event.new(
|
|
32
|
+
event_id: payload.fetch("event_id"),
|
|
33
|
+
provider: payload.fetch("provider"),
|
|
34
|
+
model: payload.fetch("model"),
|
|
35
|
+
token_usage: TokenUsage.from_hash(token_usage),
|
|
36
|
+
pricing_mode: payload["pricing_mode"],
|
|
37
|
+
cost: cost,
|
|
38
|
+
tags: payload.fetch("tags"),
|
|
39
|
+
latency_ms: payload["latency_ms"],
|
|
40
|
+
stream: payload.fetch("stream"),
|
|
41
|
+
usage_source: payload["usage_source"],
|
|
42
|
+
provider_response_id: payload["provider_response_id"],
|
|
43
|
+
tracked_at: Time.iso8601(payload.fetch("tracked_at"))
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def row_for(event)
|
|
50
|
+
now = Time.now.utc
|
|
51
|
+
{
|
|
52
|
+
event_id: event.event_id,
|
|
53
|
+
total_cost: event.total_cost,
|
|
54
|
+
tracked_at: event.tracked_at,
|
|
55
|
+
payload: JSON.generate(payload_for(event)),
|
|
56
|
+
attempts: 0,
|
|
57
|
+
created_at: now,
|
|
58
|
+
updated_at: now
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def payload_for(event)
|
|
63
|
+
event.to_h.merge(
|
|
64
|
+
schema_version: PAYLOAD_SCHEMA_VERSION,
|
|
65
|
+
event_id: event.event_id,
|
|
66
|
+
provider: event.provider,
|
|
67
|
+
model: event.model,
|
|
68
|
+
tracked_at: event.tracked_at.iso8601(6)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def insert_row(row)
|
|
73
|
+
connection = LlmCostTracker::Ledger::Call.connection
|
|
74
|
+
if connection.transaction_open?
|
|
75
|
+
insert_with_separate_connection(row)
|
|
76
|
+
else
|
|
77
|
+
execute_insert(connection, row)
|
|
78
|
+
end
|
|
79
|
+
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
80
|
+
raise LlmCostTracker::Error,
|
|
81
|
+
"ledger inbox could not checkout a separate database connection: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def insert_with_separate_connection(row)
|
|
85
|
+
pool = LlmCostTracker::Ledger::Call.connection_pool
|
|
86
|
+
connection = pool.checkout
|
|
87
|
+
begin
|
|
88
|
+
connection.transaction(requires_new: true) { execute_insert(connection, row) }
|
|
89
|
+
ensure
|
|
90
|
+
pool.checkin(connection)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute_insert(connection, row)
|
|
95
|
+
columns = row.keys
|
|
96
|
+
quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
|
|
97
|
+
quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
|
|
98
|
+
table = connection.quote_table_name(Event.table_name)
|
|
99
|
+
|
|
100
|
+
connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb}
RENAMED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../ingestor_lease"
|
|
4
|
-
|
|
5
3
|
module LlmCostTracker
|
|
6
|
-
module
|
|
7
|
-
class
|
|
4
|
+
module Ingestion
|
|
5
|
+
class LeaseClaim
|
|
8
6
|
LEASE_NAME = "default"
|
|
9
7
|
|
|
10
8
|
def initialize(identity:, seconds:)
|
|
@@ -14,9 +12,9 @@ module LlmCostTracker
|
|
|
14
12
|
|
|
15
13
|
def acquire
|
|
16
14
|
now = Time.now.utc
|
|
17
|
-
LlmCostTracker::
|
|
18
|
-
lease = LlmCostTracker::
|
|
19
|
-
lease ||= LlmCostTracker::
|
|
15
|
+
LlmCostTracker::Ingestion::Lease.transaction do
|
|
16
|
+
lease = LlmCostTracker::Ingestion::Lease.lock.find_by(name: LEASE_NAME)
|
|
17
|
+
lease ||= LlmCostTracker::Ingestion::Lease.create!(name: LEASE_NAME)
|
|
20
18
|
next false unless available?(lease, now)
|
|
21
19
|
|
|
22
20
|
lease.update!(locked_by: identity, locked_until: now + seconds)
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/kernel/reporting"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
|
|
5
|
-
require_relative "
|
|
6
|
+
require_relative "inbox"
|
|
7
|
+
require_relative "batch"
|
|
8
|
+
require_relative "lease_claim"
|
|
6
9
|
require_relative "../logging"
|
|
7
|
-
require_relative "active_record_connection_cleanup"
|
|
8
|
-
require_relative "active_record_inbox"
|
|
9
|
-
require_relative "active_record_inbox_batch"
|
|
10
|
-
require_relative "active_record_ingestor_lease"
|
|
11
10
|
|
|
12
11
|
module LlmCostTracker
|
|
13
|
-
module
|
|
14
|
-
class
|
|
12
|
+
module Ingestion
|
|
13
|
+
class Worker
|
|
15
14
|
INTERVAL_SECONDS = 0.25
|
|
16
15
|
IDLE_INTERVAL_SECONDS = 1.0
|
|
17
16
|
MAX_IDLE_INTERVAL_SECONDS = 5.0
|
|
@@ -19,13 +18,12 @@ module LlmCostTracker
|
|
|
19
18
|
FLUSH_TIMEOUT_SECONDS = 10
|
|
20
19
|
class << self
|
|
21
20
|
def ensure_started
|
|
22
|
-
return unless ActiveRecordInbox.enabled?
|
|
23
|
-
|
|
24
21
|
thread = mutex.synchronize do
|
|
25
22
|
reset_after_fork!
|
|
26
23
|
unless @thread&.alive?
|
|
27
24
|
@stop_requested = false
|
|
28
|
-
generation =
|
|
25
|
+
@generation = @generation.to_i + 1
|
|
26
|
+
generation = @generation
|
|
29
27
|
@thread = Thread.new { run(generation) }
|
|
30
28
|
@thread.name = "llm_cost_tracker_ingestor" if @thread.respond_to?(:name=)
|
|
31
29
|
@thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
|
|
@@ -36,23 +34,27 @@ module LlmCostTracker
|
|
|
36
34
|
end
|
|
37
35
|
|
|
38
36
|
def flush!(timeout: FLUSH_TIMEOUT_SECONDS, require_lease: false)
|
|
39
|
-
|
|
37
|
+
Ingestion.ensure_current_schema!
|
|
40
38
|
|
|
41
39
|
deadline = Time.now.utc + timeout
|
|
42
40
|
loop do
|
|
43
|
-
return true unless
|
|
41
|
+
return true unless Ingestion::Batch.new(identity: identity).pending?
|
|
44
42
|
return false if Time.now.utc >= deadline
|
|
45
43
|
|
|
46
44
|
processed = ingest_once(require_lease: require_lease)
|
|
47
|
-
|
|
45
|
+
next unless processed.zero?
|
|
46
|
+
|
|
47
|
+
duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
|
|
48
|
+
return false unless duration.positive?
|
|
49
|
+
|
|
50
|
+
sleep(duration)
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def shutdown!(timeout: FLUSH_TIMEOUT_SECONDS, drain: true)
|
|
52
|
-
ActiveRecordInbox.reset!
|
|
53
55
|
thread = mutex.synchronize do
|
|
54
56
|
@stop_requested = true
|
|
55
|
-
|
|
57
|
+
@generation = @generation.to_i + 1
|
|
56
58
|
@thread
|
|
57
59
|
end
|
|
58
60
|
wake_thread(thread)
|
|
@@ -68,7 +70,7 @@ module LlmCostTracker
|
|
|
68
70
|
def reset!
|
|
69
71
|
thread = mutex.synchronize do
|
|
70
72
|
@stop_requested = true
|
|
71
|
-
|
|
73
|
+
@generation = @generation.to_i + 1
|
|
72
74
|
thread = @thread
|
|
73
75
|
@thread = nil
|
|
74
76
|
@pid = nil
|
|
@@ -79,11 +81,11 @@ module LlmCostTracker
|
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
def ingest_once(require_lease: true)
|
|
82
|
-
|
|
83
|
-
return 0 unless
|
|
84
|
-
return 0 if require_lease && !
|
|
84
|
+
batch = Ingestion::Batch.new(identity: identity)
|
|
85
|
+
return 0 unless batch.claimable?
|
|
86
|
+
return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
batch.ingest
|
|
87
89
|
rescue StandardError => e
|
|
88
90
|
handle_error(e)
|
|
89
91
|
0
|
|
@@ -91,15 +93,17 @@ module LlmCostTracker
|
|
|
91
93
|
|
|
92
94
|
private
|
|
93
95
|
|
|
94
|
-
def mutex
|
|
96
|
+
def mutex
|
|
97
|
+
@mutex ||= Mutex.new
|
|
98
|
+
end
|
|
95
99
|
|
|
96
100
|
def run(generation)
|
|
97
101
|
idle_interval = IDLE_INTERVAL_SECONDS
|
|
98
102
|
loop do
|
|
99
|
-
break if stop_requested
|
|
103
|
+
break if mutex.synchronize { @stop_requested || generation != @generation }
|
|
100
104
|
|
|
101
105
|
processed = executor_wrap { ingest_once }
|
|
102
|
-
|
|
106
|
+
release_connection!
|
|
103
107
|
if processed.zero?
|
|
104
108
|
sleep(idle_interval)
|
|
105
109
|
idle_interval = [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
|
|
@@ -108,21 +112,14 @@ module LlmCostTracker
|
|
|
108
112
|
end
|
|
109
113
|
rescue StandardError => e
|
|
110
114
|
handle_error(e)
|
|
111
|
-
|
|
115
|
+
release_connection!
|
|
112
116
|
sleep(idle_interval)
|
|
113
117
|
end
|
|
114
118
|
ensure
|
|
115
|
-
|
|
119
|
+
release_connection!
|
|
116
120
|
mutex.synchronize { @thread = nil if @thread.equal?(Thread.current) }
|
|
117
121
|
end
|
|
118
122
|
|
|
119
|
-
def sleep_until_next_flush(deadline)
|
|
120
|
-
duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
|
|
121
|
-
sleep(duration) if duration.positive?
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def stop_requested?(generation) = mutex.synchronize { @stop_requested || generation != @generation }
|
|
125
|
-
|
|
126
123
|
def reset_after_fork!
|
|
127
124
|
return if @pid == Process.pid
|
|
128
125
|
|
|
@@ -131,8 +128,6 @@ module LlmCostTracker
|
|
|
131
128
|
@identity = nil
|
|
132
129
|
end
|
|
133
130
|
|
|
134
|
-
def next_generation = (@generation = @generation.to_i + 1)
|
|
135
|
-
|
|
136
131
|
def wake_thread(thread)
|
|
137
132
|
thread&.wakeup if thread&.alive?
|
|
138
133
|
rescue ThreadError
|
|
@@ -147,26 +142,21 @@ module LlmCostTracker
|
|
|
147
142
|
end
|
|
148
143
|
|
|
149
144
|
def rails_executor
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
Rails.application.executor
|
|
145
|
+
Rails.application.try(:executor)
|
|
153
146
|
rescue StandardError
|
|
154
147
|
nil
|
|
155
148
|
end
|
|
156
149
|
|
|
157
|
-
def identity
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def pending_events? = inbox_batch.pending?
|
|
162
|
-
|
|
163
|
-
def claimable_events? = inbox_batch.claimable?
|
|
164
|
-
|
|
165
|
-
def inbox_batch = ActiveRecordInboxBatch.new(identity: identity)
|
|
150
|
+
def identity
|
|
151
|
+
@identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
152
|
+
end
|
|
166
153
|
|
|
167
154
|
def handle_error(error)
|
|
168
|
-
Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}")
|
|
169
|
-
|
|
155
|
+
Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def release_connection!
|
|
159
|
+
suppress(StandardError) { ActiveRecord::Base.connection_handler.clear_active_connections! }
|
|
170
160
|
end
|
|
171
161
|
end
|
|
172
162
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "doctor/check"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "ledger"
|
|
8
|
+
require_relative "ingestion/lease_claim"
|
|
9
|
+
require_relative "ingestion/inbox"
|
|
10
|
+
require_relative "ingestion/batch"
|
|
11
|
+
require_relative "ingestion/worker"
|
|
12
|
+
|
|
13
|
+
module LlmCostTracker
|
|
14
|
+
module Ingestion
|
|
15
|
+
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def ensure_current_schema!
|
|
19
|
+
unless Ledger::Call.table_exists?
|
|
20
|
+
raise Error, "llm_api_calls table is missing; run install generator and migrate"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
schema_errors = Ledger::Schema::Calls.current_schema_errors
|
|
24
|
+
message = "llm_api_calls table is not on the current schema: #{schema_errors.join('; ')}"
|
|
25
|
+
raise Error, message if schema_errors.any?
|
|
26
|
+
|
|
27
|
+
period_total_errors = Ledger::Schema::PeriodTotals.current_schema_errors
|
|
28
|
+
return if period_total_errors.empty?
|
|
29
|
+
|
|
30
|
+
message = "llm_cost_tracker_period_totals table is not on the current schema: " \
|
|
31
|
+
"#{period_total_errors.join('; ')}; " \
|
|
32
|
+
"run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
|
|
33
|
+
raise Error, message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def verify
|
|
37
|
+
unless LlmCostTracker::Ledger::Call.table_exists?
|
|
38
|
+
return [
|
|
39
|
+
LlmCostTracker::Doctor::Check.new(
|
|
40
|
+
:error,
|
|
41
|
+
"active_record",
|
|
42
|
+
"llm_api_calls table is missing; run install generator and migrate"
|
|
43
|
+
)
|
|
44
|
+
]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
[capture_check]
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
[LlmCostTracker::Doctor::Check.new(:error, "active_record", "#{e.class}: #{e.message}")]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def capture_check
|
|
55
|
+
provider, model = sample_priced_identity
|
|
56
|
+
response_id = "lct_verify_#{SecureRandom.hex(8)}"
|
|
57
|
+
notifications = []
|
|
58
|
+
subscription = subscribe_to_verification(response_id, notifications)
|
|
59
|
+
|
|
60
|
+
event = LlmCostTracker.track(
|
|
61
|
+
provider: provider,
|
|
62
|
+
model: model,
|
|
63
|
+
input_tokens: 1,
|
|
64
|
+
output_tokens: 1,
|
|
65
|
+
provider_response_id: response_id,
|
|
66
|
+
feature: VERIFY_TAG
|
|
67
|
+
)
|
|
68
|
+
LlmCostTracker.flush!
|
|
69
|
+
persisted = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id).exists?
|
|
70
|
+
|
|
71
|
+
return capture_success if persisted && notifications.any?
|
|
72
|
+
|
|
73
|
+
LlmCostTracker::Doctor::Check.new(
|
|
74
|
+
:error,
|
|
75
|
+
"active_record capture",
|
|
76
|
+
capture_failure_message(persisted, notifications)
|
|
77
|
+
)
|
|
78
|
+
rescue LlmCostTracker::BudgetExceededError => e
|
|
79
|
+
LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
|
|
80
|
+
rescue LlmCostTracker::Error => e
|
|
81
|
+
LlmCostTracker::Doctor::Check.new(:error, "active_record capture", e.message)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
|
|
84
|
+
ensure
|
|
85
|
+
cleanup_verification_call(response_id) if response_id
|
|
86
|
+
LlmCostTracker::Ingestion::Event.where(event_id: event.event_id).delete_all if event
|
|
87
|
+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def subscribe_to_verification(response_id, notifications)
|
|
91
|
+
ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
|
|
92
|
+
notifications << payload if payload[:provider_response_id] == response_id
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def capture_success
|
|
97
|
+
LlmCostTracker::Doctor::Check.new(
|
|
98
|
+
:ok,
|
|
99
|
+
"active_record capture",
|
|
100
|
+
"manual event emitted and persisted through durable inbox"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def capture_failure_message(persisted, notifications)
|
|
105
|
+
missing = []
|
|
106
|
+
missing << "notification" if notifications.empty?
|
|
107
|
+
missing << "persisted row" unless persisted
|
|
108
|
+
"missing #{missing.join(' and ')} for synthetic manual event"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cleanup_verification_call(response_id)
|
|
112
|
+
relation = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id)
|
|
113
|
+
rows = relation.pluck(:id, :tracked_at, :total_cost)
|
|
114
|
+
return if rows.empty?
|
|
115
|
+
|
|
116
|
+
relation.delete_all
|
|
117
|
+
LlmCostTracker::Ledger::Rollups.decrement!(rows)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def sample_priced_identity
|
|
121
|
+
key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
|
|
122
|
+
model_id.include?("/") && prices[:input] && prices[:output]
|
|
123
|
+
end&.first
|
|
124
|
+
provider, model = key.to_s.split("/", 2)
|
|
125
|
+
[provider || "openai", model || "gpt-4o-mini"]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|