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,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module RequestUrl
|
|
7
|
-
class << self
|
|
8
|
-
def label(value)
|
|
9
|
-
uri = URI.parse(value.to_s)
|
|
10
|
-
uri.query = nil
|
|
11
|
-
uri.fragment = nil
|
|
12
|
-
uri.user = nil if uri.respond_to?(:user=)
|
|
13
|
-
uri.password = nil if uri.respond_to?(:password=)
|
|
14
|
-
uri.to_s
|
|
15
|
-
rescue URI::InvalidURIError
|
|
16
|
-
value.to_s.split("?", 2).first
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
|
|
5
|
-
require_relative "active_record_inbox"
|
|
6
|
-
require_relative "active_record_ingestor"
|
|
7
|
-
require_relative "active_record_store"
|
|
8
|
-
|
|
9
|
-
module LlmCostTracker
|
|
10
|
-
module Storage
|
|
11
|
-
VerificationResult = Data.define(:status, :name, :message)
|
|
12
|
-
|
|
13
|
-
class ActiveRecordBackend
|
|
14
|
-
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
15
|
-
|
|
16
|
-
class << self
|
|
17
|
-
def save(event)
|
|
18
|
-
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
19
|
-
|
|
20
|
-
if ActiveRecordInbox.enabled?
|
|
21
|
-
ActiveRecordInbox.save(event)
|
|
22
|
-
else
|
|
23
|
-
ActiveRecordStore.save(event)
|
|
24
|
-
end
|
|
25
|
-
event
|
|
26
|
-
rescue LoadError => e
|
|
27
|
-
raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def verify
|
|
31
|
-
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
32
|
-
|
|
33
|
-
unless LlmCostTracker::LlmApiCall.table_exists?
|
|
34
|
-
return [
|
|
35
|
-
VerificationResult.new(
|
|
36
|
-
:error,
|
|
37
|
-
"active_record",
|
|
38
|
-
"llm_api_calls table is missing; run install generator and migrate"
|
|
39
|
-
)
|
|
40
|
-
]
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
[active_record_capture_check]
|
|
44
|
-
rescue LoadError => e
|
|
45
|
-
[VerificationResult.new(:error, "active_record", "unavailable: #{e.message}")]
|
|
46
|
-
rescue StandardError => e
|
|
47
|
-
[VerificationResult.new(:error, "active_record", "#{e.class}: #{e.message}")]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def prune(cutoff:, batch_size:)
|
|
51
|
-
require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
52
|
-
|
|
53
|
-
ActiveRecordStore.prune(cutoff: cutoff, batch_size: batch_size)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
def active_record_capture_check
|
|
59
|
-
return active_record_inbox_capture_check if ActiveRecordInbox.enabled?
|
|
60
|
-
|
|
61
|
-
provider, model = sample_priced_identity
|
|
62
|
-
response_id = "lct_verify_#{SecureRandom.hex(8)}"
|
|
63
|
-
notifications = []
|
|
64
|
-
persisted = false
|
|
65
|
-
subscription = subscribe_to_verification(response_id, notifications)
|
|
66
|
-
|
|
67
|
-
LlmCostTracker::LlmApiCall.transaction do
|
|
68
|
-
LlmCostTracker.track(
|
|
69
|
-
provider: provider,
|
|
70
|
-
model: model,
|
|
71
|
-
input_tokens: 1,
|
|
72
|
-
output_tokens: 1,
|
|
73
|
-
provider_response_id: response_id,
|
|
74
|
-
feature: VERIFY_TAG
|
|
75
|
-
)
|
|
76
|
-
persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
|
|
77
|
-
raise ActiveRecord::Rollback
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
return active_record_capture_success if persisted && notifications.any?
|
|
81
|
-
|
|
82
|
-
VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
|
|
83
|
-
rescue LlmCostTracker::BudgetExceededError => e
|
|
84
|
-
VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
|
|
85
|
-
rescue LlmCostTracker::Error => e
|
|
86
|
-
VerificationResult.new(:error, "active_record capture", e.message)
|
|
87
|
-
rescue StandardError => e
|
|
88
|
-
VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
|
|
89
|
-
ensure
|
|
90
|
-
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def active_record_inbox_capture_check
|
|
94
|
-
provider, model = sample_priced_identity
|
|
95
|
-
response_id = "lct_verify_#{SecureRandom.hex(8)}"
|
|
96
|
-
notifications = []
|
|
97
|
-
subscription = subscribe_to_verification(response_id, notifications)
|
|
98
|
-
|
|
99
|
-
event = LlmCostTracker.track(
|
|
100
|
-
provider: provider,
|
|
101
|
-
model: model,
|
|
102
|
-
input_tokens: 1,
|
|
103
|
-
output_tokens: 1,
|
|
104
|
-
provider_response_id: response_id,
|
|
105
|
-
feature: VERIFY_TAG
|
|
106
|
-
)
|
|
107
|
-
LlmCostTracker.flush!
|
|
108
|
-
persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
|
|
109
|
-
|
|
110
|
-
if persisted && notifications.any?
|
|
111
|
-
return active_record_capture_success("manual event emitted and persisted through durable inbox")
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
|
|
115
|
-
rescue LlmCostTracker::BudgetExceededError => e
|
|
116
|
-
VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
|
|
117
|
-
rescue LlmCostTracker::Error => e
|
|
118
|
-
VerificationResult.new(:error, "active_record capture", e.message)
|
|
119
|
-
rescue StandardError => e
|
|
120
|
-
VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
|
|
121
|
-
ensure
|
|
122
|
-
cleanup_verification_call(response_id) if response_id
|
|
123
|
-
LlmCostTracker::InboxEvent.where(event_id: event.event_id).delete_all if event
|
|
124
|
-
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def subscribe_to_verification(response_id, notifications)
|
|
128
|
-
ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
|
|
129
|
-
notifications << payload if payload[:provider_response_id] == response_id
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def active_record_capture_success(message = "manual event emitted and persisted inside rollback")
|
|
134
|
-
VerificationResult.new(
|
|
135
|
-
:ok,
|
|
136
|
-
"active_record capture",
|
|
137
|
-
message
|
|
138
|
-
)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def capture_failure_message(persisted, notifications)
|
|
142
|
-
missing = []
|
|
143
|
-
missing << "notification" if notifications.empty?
|
|
144
|
-
missing << "persisted row" unless persisted
|
|
145
|
-
"missing #{missing.join(' and ')} for synthetic manual event"
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def cleanup_verification_call(response_id)
|
|
149
|
-
relation = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id)
|
|
150
|
-
rows = relation.pluck(:id, :tracked_at, :total_cost)
|
|
151
|
-
return if rows.empty?
|
|
152
|
-
|
|
153
|
-
relation.delete_all
|
|
154
|
-
ActiveRecordRollups.decrement!(rows)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def sample_priced_identity
|
|
158
|
-
key = LlmCostTracker::PriceRegistry.builtin_prices.find do |model_id, prices|
|
|
159
|
-
model_id.include?("/") && prices[:input] && prices[:output]
|
|
160
|
-
end&.first
|
|
161
|
-
provider, model = key.to_s.split("/", 2)
|
|
162
|
-
[provider || "openai", model || "gpt-4o-mini"]
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
end
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "time"
|
|
5
|
-
|
|
6
|
-
require_relative "../cost"
|
|
7
|
-
require_relative "../event"
|
|
8
|
-
require_relative "../inbox_event"
|
|
9
|
-
require_relative "active_record_periods"
|
|
10
|
-
|
|
11
|
-
module LlmCostTracker
|
|
12
|
-
module Storage
|
|
13
|
-
class ActiveRecordInbox
|
|
14
|
-
TABLE_NAME = "llm_cost_tracker_inbox_events"
|
|
15
|
-
LEASE_TABLE_NAME = "llm_cost_tracker_ingestor_leases"
|
|
16
|
-
MAX_ATTEMPTS = 5
|
|
17
|
-
|
|
18
|
-
class << self
|
|
19
|
-
def reset!
|
|
20
|
-
remove_instance_variable(:@enabled) if instance_variable_defined?(:@enabled)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def enabled?
|
|
24
|
-
return @enabled unless @enabled.nil?
|
|
25
|
-
|
|
26
|
-
model = LlmCostTracker::LlmApiCall
|
|
27
|
-
@enabled = model.columns_hash.key?("event_id") &&
|
|
28
|
-
model.connection.data_source_exists?(TABLE_NAME) &&
|
|
29
|
-
model.connection.data_source_exists?(LEASE_TABLE_NAME)
|
|
30
|
-
rescue StandardError
|
|
31
|
-
@enabled = false
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def save(event)
|
|
35
|
-
insert_row(row_for(event))
|
|
36
|
-
ActiveRecordIngestor.ensure_started
|
|
37
|
-
event
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def pending_period_totals(periods, time:)
|
|
41
|
-
return periods.to_h { |period| [period, 0.0] } unless enabled?
|
|
42
|
-
|
|
43
|
-
periods.to_h do |period|
|
|
44
|
-
[period, pending_period_total(period, time)]
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def event_from_row(row)
|
|
49
|
-
payload = JSON.parse(row.payload)
|
|
50
|
-
cost = payload["cost"] && LlmCostTracker::Cost.new(**symbolize_keys(payload["cost"]))
|
|
51
|
-
|
|
52
|
-
LlmCostTracker::Event.new(
|
|
53
|
-
event_id: payload.fetch("event_id"),
|
|
54
|
-
provider: payload.fetch("provider"),
|
|
55
|
-
model: payload.fetch("model"),
|
|
56
|
-
input_tokens: payload.fetch("input_tokens"),
|
|
57
|
-
output_tokens: payload.fetch("output_tokens"),
|
|
58
|
-
total_tokens: payload.fetch("total_tokens"),
|
|
59
|
-
cache_read_input_tokens: payload.fetch("cache_read_input_tokens"),
|
|
60
|
-
cache_write_input_tokens: payload.fetch("cache_write_input_tokens"),
|
|
61
|
-
hidden_output_tokens: payload.fetch("hidden_output_tokens"),
|
|
62
|
-
pricing_mode: payload["pricing_mode"],
|
|
63
|
-
cost: cost,
|
|
64
|
-
tags: payload.fetch("tags"),
|
|
65
|
-
latency_ms: payload["latency_ms"],
|
|
66
|
-
stream: payload.fetch("stream"),
|
|
67
|
-
usage_source: payload["usage_source"],
|
|
68
|
-
provider_response_id: payload["provider_response_id"],
|
|
69
|
-
tracked_at: Time.iso8601(payload.fetch("tracked_at"))
|
|
70
|
-
)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
def row_for(event)
|
|
76
|
-
now = Time.now.utc
|
|
77
|
-
{
|
|
78
|
-
event_id: event.event_id,
|
|
79
|
-
total_cost: event.cost&.total_cost,
|
|
80
|
-
tracked_at: event.tracked_at,
|
|
81
|
-
payload: JSON.generate(payload_for(event)),
|
|
82
|
-
attempts: 0,
|
|
83
|
-
created_at: now,
|
|
84
|
-
updated_at: now
|
|
85
|
-
}
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def payload_for(event)
|
|
89
|
-
{
|
|
90
|
-
event_id: event.event_id,
|
|
91
|
-
provider: event.provider,
|
|
92
|
-
model: event.model,
|
|
93
|
-
input_tokens: event.input_tokens,
|
|
94
|
-
output_tokens: event.output_tokens,
|
|
95
|
-
total_tokens: event.total_tokens,
|
|
96
|
-
cache_read_input_tokens: event.cache_read_input_tokens,
|
|
97
|
-
cache_write_input_tokens: event.cache_write_input_tokens,
|
|
98
|
-
hidden_output_tokens: event.hidden_output_tokens,
|
|
99
|
-
pricing_mode: event.pricing_mode,
|
|
100
|
-
cost: event.cost&.to_h,
|
|
101
|
-
tags: event.tags || {},
|
|
102
|
-
latency_ms: event.latency_ms,
|
|
103
|
-
stream: event.stream,
|
|
104
|
-
usage_source: event.usage_source,
|
|
105
|
-
provider_response_id: event.provider_response_id,
|
|
106
|
-
tracked_at: event.tracked_at.iso8601(6)
|
|
107
|
-
}
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def insert_row(row)
|
|
111
|
-
connection = LlmCostTracker::LlmApiCall.connection
|
|
112
|
-
if connection.transaction_open?
|
|
113
|
-
insert_with_separate_connection(row)
|
|
114
|
-
else
|
|
115
|
-
execute_insert(connection, row)
|
|
116
|
-
end
|
|
117
|
-
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
118
|
-
raise LlmCostTracker::Error,
|
|
119
|
-
"ActiveRecord inbox could not checkout a separate database connection: #{e.message}"
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def insert_with_separate_connection(row)
|
|
123
|
-
pool = LlmCostTracker::LlmApiCall.connection_pool
|
|
124
|
-
connection = pool.checkout
|
|
125
|
-
begin
|
|
126
|
-
connection.transaction(requires_new: true) { execute_insert(connection, row) }
|
|
127
|
-
ensure
|
|
128
|
-
pool.checkin(connection)
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def execute_insert(connection, row)
|
|
133
|
-
columns = row.keys
|
|
134
|
-
quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
|
|
135
|
-
quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
|
|
136
|
-
table = connection.quote_table_name(TABLE_NAME)
|
|
137
|
-
|
|
138
|
-
connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def pending_period_total(period, time)
|
|
142
|
-
LlmCostTracker::InboxEvent
|
|
143
|
-
.where("attempts < ?", MAX_ATTEMPTS)
|
|
144
|
-
.where(tracked_at: period_range(period, time))
|
|
145
|
-
.sum(:total_cost)
|
|
146
|
-
.to_f
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def period_range(period, time)
|
|
150
|
-
utc_time = time.to_time.utc
|
|
151
|
-
ActiveRecordPeriods.range_start(period, utc_time)..utc_time
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def symbolize_keys(hash)
|
|
155
|
-
hash.transform_keys(&:to_sym)
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
end
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "active_record_inbox"
|
|
4
|
-
require_relative "active_record_periods"
|
|
5
|
-
require_relative "active_record_rollups"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module Storage
|
|
9
|
-
class ActiveRecordPeriodTotals
|
|
10
|
-
def self.call(periods, time:)
|
|
11
|
-
new(periods, time: time).totals
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def initialize(periods, time:)
|
|
15
|
-
@periods = ActiveRecordPeriods.valid_keys(periods)
|
|
16
|
-
@time = time
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def totals
|
|
20
|
-
return {} if periods.empty?
|
|
21
|
-
return ActiveRecordRollups.period_totals(periods, time: time) unless ActiveRecordInbox.enabled?
|
|
22
|
-
|
|
23
|
-
snapshot_totals
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
attr_reader :periods, :time
|
|
29
|
-
|
|
30
|
-
def snapshot_totals
|
|
31
|
-
values = periods.to_h { |period| [period, 0.0] }
|
|
32
|
-
connection.select_all(snapshot_sql).each do |row|
|
|
33
|
-
values[row.fetch("period_key").to_sym] = row.fetch("total_cost").to_f
|
|
34
|
-
end
|
|
35
|
-
values
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def snapshot_sql
|
|
39
|
-
periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def snapshot_select(period)
|
|
43
|
-
start = range_start_for(period)
|
|
44
|
-
"SELECT #{connection.quote(period.to_s)} AS period_key, " \
|
|
45
|
-
"(#{stored_total_sql(period, start)}) + (#{pending_total_sql(start)}) AS total_cost"
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def stored_total_sql(period, start)
|
|
49
|
-
period_totals_table? ? rollup_total_sql(period) : ledger_total_sql(start)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def rollup_total_sql(period)
|
|
53
|
-
table = connection.quote_table_name("llm_cost_tracker_period_totals")
|
|
54
|
-
"COALESCE((SELECT total_cost FROM #{table} " \
|
|
55
|
-
"WHERE period = #{connection.quote(ActiveRecordPeriods::PERIODS.fetch(period))} " \
|
|
56
|
-
"AND period_start = #{connection.quote(ActiveRecordPeriods.bucket(period, time))} LIMIT 1), 0)"
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def ledger_total_sql(start)
|
|
60
|
-
table = LlmCostTracker::LlmApiCall.quoted_table_name
|
|
61
|
-
total_cost = connection.quote_column_name("total_cost")
|
|
62
|
-
tracked_at = connection.quote_column_name("tracked_at")
|
|
63
|
-
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
64
|
-
"WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def pending_total_sql(start)
|
|
68
|
-
table = connection.quote_table_name(ActiveRecordInbox::TABLE_NAME)
|
|
69
|
-
total_cost = connection.quote_column_name("total_cost")
|
|
70
|
-
tracked_at = connection.quote_column_name("tracked_at")
|
|
71
|
-
attempts = connection.quote_column_name("attempts")
|
|
72
|
-
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
73
|
-
"WHERE #{attempts} < #{ActiveRecordInbox::MAX_ATTEMPTS} " \
|
|
74
|
-
"AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def period_totals_table? = connection.data_source_exists?("llm_cost_tracker_period_totals")
|
|
78
|
-
|
|
79
|
-
def range_start_for(period) = ActiveRecordPeriods.range_start(period, time)
|
|
80
|
-
|
|
81
|
-
def connection = LlmCostTracker::LlmApiCall.connection
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bigdecimal"
|
|
4
|
-
|
|
5
|
-
require_relative "active_record_periods"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module Storage
|
|
9
|
-
class ActiveRecordRollupBatch
|
|
10
|
-
def self.rows(events)
|
|
11
|
-
new(events).rows
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def initialize(events)
|
|
15
|
-
@events = events
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def rows
|
|
19
|
-
totals.map do |(period, period_start), total_cost|
|
|
20
|
-
{
|
|
21
|
-
period: period,
|
|
22
|
-
period_start: period_start,
|
|
23
|
-
total_cost: total_cost
|
|
24
|
-
}
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
attr_reader :events
|
|
31
|
-
|
|
32
|
-
def totals
|
|
33
|
-
events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
|
|
34
|
-
ActiveRecordPeriods::PERIODS.each do |period, name|
|
|
35
|
-
rows[[name, ActiveRecordPeriods.bucket(period, event.tracked_at)]] += BigDecimal(event.cost.total_cost.to_s)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../active_record_adapter"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module Storage
|
|
7
|
-
class ActiveRecordRollupUpsertSql
|
|
8
|
-
def self.call(model)
|
|
9
|
-
new(model).call
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def initialize(model)
|
|
13
|
-
@model = model
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def call
|
|
17
|
-
return Arel.sql(mysql_sql) if ActiveRecordAdapter.mysql?(connection)
|
|
18
|
-
return Arel.sql(postgres_sql) if ActiveRecordAdapter.postgresql?(connection)
|
|
19
|
-
|
|
20
|
-
ActiveRecordAdapter.ensure_supported!(connection)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
attr_reader :model
|
|
26
|
-
|
|
27
|
-
def postgres_sql
|
|
28
|
-
total_cost = connection.quote_column_name("total_cost")
|
|
29
|
-
updated_at = connection.quote_column_name("updated_at")
|
|
30
|
-
|
|
31
|
-
"#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
32
|
-
"#{updated_at} = excluded.#{updated_at}"
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def mysql_sql
|
|
36
|
-
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def connection = model.connection
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bigdecimal"
|
|
4
|
-
|
|
5
|
-
require_relative "active_record_periods"
|
|
6
|
-
require_relative "active_record_rollup_batch"
|
|
7
|
-
require_relative "active_record_rollup_upsert_sql"
|
|
8
|
-
|
|
9
|
-
module LlmCostTracker
|
|
10
|
-
module Storage
|
|
11
|
-
class ActiveRecordRollups
|
|
12
|
-
class << self
|
|
13
|
-
def reset!
|
|
14
|
-
remove_instance_variable(:@period_totals_enabled) if instance_variable_defined?(:@period_totals_enabled)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def increment!(event)
|
|
18
|
-
return unless event.cost&.total_cost
|
|
19
|
-
return unless period_totals_enabled?
|
|
20
|
-
|
|
21
|
-
model = period_total_model
|
|
22
|
-
model.upsert_all(
|
|
23
|
-
period_rows(event),
|
|
24
|
-
on_duplicate: ActiveRecordRollupUpsertSql.call(model),
|
|
25
|
-
record_timestamps: true,
|
|
26
|
-
unique_by: unique_by(model, %i[period period_start])
|
|
27
|
-
)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def increment_many!(events)
|
|
31
|
-
events = Array(events).select { |event| event.cost&.total_cost }
|
|
32
|
-
return if events.empty?
|
|
33
|
-
return unless period_totals_enabled?
|
|
34
|
-
|
|
35
|
-
model = period_total_model
|
|
36
|
-
model.upsert_all(
|
|
37
|
-
ActiveRecordRollupBatch.rows(events),
|
|
38
|
-
on_duplicate: ActiveRecordRollupUpsertSql.call(model),
|
|
39
|
-
record_timestamps: true,
|
|
40
|
-
unique_by: unique_by(model, %i[period period_start])
|
|
41
|
-
)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def decrement!(call_rows)
|
|
45
|
-
return unless period_totals_enabled?
|
|
46
|
-
|
|
47
|
-
totals = period_decrement_totals(call_rows)
|
|
48
|
-
return if totals.empty?
|
|
49
|
-
|
|
50
|
-
apply_decrements(totals)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def period_totals(periods, time: Time.now.utc)
|
|
54
|
-
periods = ActiveRecordPeriods.valid_keys(periods)
|
|
55
|
-
return {} if periods.empty?
|
|
56
|
-
|
|
57
|
-
if period_totals_enabled?
|
|
58
|
-
rollup_period_totals(periods, time)
|
|
59
|
-
else
|
|
60
|
-
periods.to_h { |period| [period, fallback_period_total(period, time)] }
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
def period_rows(event)
|
|
67
|
-
ActiveRecordPeriods::PERIODS.map do |period, name|
|
|
68
|
-
{
|
|
69
|
-
period: name,
|
|
70
|
-
period_start: ActiveRecordPeriods.bucket(period, event.tracked_at),
|
|
71
|
-
total_cost: event.cost.total_cost
|
|
72
|
-
}
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def period_decrement_totals(call_rows)
|
|
77
|
-
call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
|
|
78
|
-
_id, tracked_at, total_cost = row
|
|
79
|
-
next unless total_cost
|
|
80
|
-
|
|
81
|
-
ActiveRecordPeriods::PERIODS.each_key do |period|
|
|
82
|
-
totals[[period, ActiveRecordPeriods.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def apply_decrements(totals)
|
|
88
|
-
model = period_total_model
|
|
89
|
-
now = Time.now.utc
|
|
90
|
-
|
|
91
|
-
totals.each do |(period, period_start), amount|
|
|
92
|
-
row = model.lock.find_by(period: ActiveRecordPeriods::PERIODS.fetch(period), period_start: period_start)
|
|
93
|
-
next unless row
|
|
94
|
-
|
|
95
|
-
row.update_columns(total_cost: decremented_total(row.total_cost, amount), updated_at: now)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def decremented_total(current, amount) = [BigDecimal(current.to_s) - amount, BigDecimal("0")].max
|
|
100
|
-
|
|
101
|
-
def rollup_period_totals(periods, time)
|
|
102
|
-
buckets = periods.to_h { |period| [period, ActiveRecordPeriods.bucket(period, time)] }
|
|
103
|
-
index = buckets.to_h { |period, bucket| [[ActiveRecordPeriods::PERIODS.fetch(period), bucket], period] }
|
|
104
|
-
totals = periods.to_h { |period| [period, 0.0] }
|
|
105
|
-
|
|
106
|
-
period_total_model
|
|
107
|
-
.where(period: periods.map { |period| ActiveRecordPeriods::PERIODS.fetch(period) },
|
|
108
|
-
period_start: buckets.values)
|
|
109
|
-
.pluck(:period, :period_start, :total_cost)
|
|
110
|
-
.each do |name, start, total|
|
|
111
|
-
period = index[[name, start.to_date]]
|
|
112
|
-
totals[period] = total.to_f if period
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
totals
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def fallback_period_total(period, time)
|
|
119
|
-
LlmCostTracker::LlmApiCall
|
|
120
|
-
.where(tracked_at: ActiveRecordPeriods.range_start(period, time)..time)
|
|
121
|
-
.sum(:total_cost)
|
|
122
|
-
.to_f
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def period_totals_enabled?
|
|
126
|
-
return @period_totals_enabled unless @period_totals_enabled.nil?
|
|
127
|
-
|
|
128
|
-
@period_totals_enabled =
|
|
129
|
-
LlmCostTracker::LlmApiCall.connection.data_source_exists?("llm_cost_tracker_period_totals")
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def period_total_model
|
|
133
|
-
require_relative "../period_total" unless defined?(LlmCostTracker::PeriodTotal)
|
|
134
|
-
|
|
135
|
-
LlmCostTracker::PeriodTotal
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def unique_by(model, column)
|
|
139
|
-
return unless model.connection.supports_insert_conflict_target?
|
|
140
|
-
|
|
141
|
-
column
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
end
|