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
|
@@ -6,22 +6,23 @@ module LlmCostTracker
|
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
8
|
def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
|
|
9
|
-
batch_size =
|
|
9
|
+
batch_size = batch_size.to_i
|
|
10
|
+
raise ArgumentError, "batch_size must be positive: #{batch_size.inspect}" unless batch_size.positive?
|
|
11
|
+
|
|
10
12
|
cutoff = resolve_cutoff(older_than, now)
|
|
11
|
-
require_relative "
|
|
13
|
+
require_relative "ledger"
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
deleted = 0
|
|
16
|
+
loop do
|
|
17
|
+
batch = prune_batch(cutoff, batch_size)
|
|
18
|
+
deleted += batch
|
|
19
|
+
break if batch < batch_size
|
|
20
|
+
end
|
|
21
|
+
deleted
|
|
14
22
|
end
|
|
15
23
|
|
|
16
24
|
private
|
|
17
25
|
|
|
18
|
-
def normalized_batch_size(value)
|
|
19
|
-
value = value.to_i
|
|
20
|
-
raise ArgumentError, "batch_size must be positive: #{value.inspect}" unless value.positive?
|
|
21
|
-
|
|
22
|
-
value
|
|
23
|
-
end
|
|
24
|
-
|
|
25
26
|
def resolve_cutoff(older_than, now)
|
|
26
27
|
cutoff = case older_than
|
|
27
28
|
when Time, DateTime then older_than.utc
|
|
@@ -46,6 +47,22 @@ module LlmCostTracker
|
|
|
46
47
|
|
|
47
48
|
now - (days * 86_400)
|
|
48
49
|
end
|
|
50
|
+
|
|
51
|
+
def prune_batch(cutoff, batch_size)
|
|
52
|
+
LlmCostTracker::Ledger::Call.transaction do
|
|
53
|
+
rows = LlmCostTracker::Ledger::Call
|
|
54
|
+
.where(tracked_at: ...cutoff)
|
|
55
|
+
.order(:id)
|
|
56
|
+
.limit(batch_size)
|
|
57
|
+
.lock
|
|
58
|
+
.pluck(:id, :tracked_at, :total_cost)
|
|
59
|
+
next 0 if rows.empty?
|
|
60
|
+
|
|
61
|
+
deleted = LlmCostTracker::Ledger::Call.where(id: rows.map(&:first)).delete_all
|
|
62
|
+
LlmCostTracker::Ledger::Rollups.decrement!(rows) if deleted.positive?
|
|
63
|
+
deleted
|
|
64
|
+
end
|
|
65
|
+
end
|
|
49
66
|
end
|
|
50
67
|
end
|
|
51
68
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/deep_dup"
|
|
4
|
+
require "active_support/isolated_execution_state"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Tags
|
|
8
|
+
module Context
|
|
9
|
+
KEY = :llm_cost_tracker_tags
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def with(tags)
|
|
13
|
+
stack = ActiveSupport::IsolatedExecutionState[KEY] || []
|
|
14
|
+
ActiveSupport::IsolatedExecutionState[KEY] = stack + [(tags || {}).deep_dup.to_h]
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
ActiveSupport::IsolatedExecutionState[KEY] = stack
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tags
|
|
21
|
+
default_tags = LlmCostTracker.configuration.default_tags
|
|
22
|
+
default_tags = default_tags.call if default_tags.respond_to?(:call)
|
|
23
|
+
|
|
24
|
+
(default_tags || {}).deep_dup.to_h.merge(
|
|
25
|
+
(ActiveSupport::IsolatedExecutionState[KEY] || []).reduce({}) { |merged, tags| merged.merge(tags) }
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def clear!
|
|
30
|
+
ActiveSupport::IsolatedExecutionState[KEY] = []
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Tags
|
|
5
|
+
module Key
|
|
6
|
+
PATTERN = /\A[\w.-]+\z/
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def validate!(key, error_class: ArgumentError)
|
|
10
|
+
key = key.to_s
|
|
11
|
+
return key if key.match?(PATTERN)
|
|
12
|
+
|
|
13
|
+
raise error_class, "invalid tag key: #{key.inspect}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Tags
|
|
7
|
+
module Sanitizer
|
|
8
|
+
REDACTED_VALUE = "[REDACTED]"
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def call(tags, config: LlmCostTracker.configuration)
|
|
12
|
+
tags = (tags || {}).to_h
|
|
13
|
+
tags.first([config.max_tag_count.to_i, 0].max).each_with_object({}) do |(key, value), sanitized|
|
|
14
|
+
sanitized[key] = sanitized_value(key, value, config)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def sanitized_value(key, value, config)
|
|
21
|
+
return REDACTED_VALUE if redacted_key?(key, config)
|
|
22
|
+
|
|
23
|
+
string = value_string(value)
|
|
24
|
+
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
25
|
+
return value if string.bytesize <= limit
|
|
26
|
+
|
|
27
|
+
string.byteslice(0, limit).to_s.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def redacted_key?(key, config)
|
|
31
|
+
normalized = normalized_key(key)
|
|
32
|
+
Array(config.redacted_tag_keys).map { |redacted_key| normalized_key(redacted_key) }.any? do |candidate|
|
|
33
|
+
redacted_key_component?(normalized, candidate)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def normalized_key(key)
|
|
38
|
+
key.to_s
|
|
39
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
40
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
41
|
+
.downcase
|
|
42
|
+
.gsub(/[^a-z0-9]+/, "_")
|
|
43
|
+
.gsub(/_+/, "_")
|
|
44
|
+
.delete_prefix("_")
|
|
45
|
+
.delete_suffix("_")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def redacted_key_component?(key, candidate)
|
|
49
|
+
key == candidate ||
|
|
50
|
+
key.start_with?("#{candidate}_") ||
|
|
51
|
+
key.end_with?("_#{candidate}") ||
|
|
52
|
+
key.include?("_#{candidate}_")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def value_string(value)
|
|
56
|
+
case value
|
|
57
|
+
when Hash, Array
|
|
58
|
+
JSON.generate(value)
|
|
59
|
+
else
|
|
60
|
+
value.to_s
|
|
61
|
+
end
|
|
62
|
+
rescue JSON::GeneratorError, TypeError
|
|
63
|
+
value.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/hash/keys"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
TokenUsage = Data.define(
|
|
7
|
+
:input_tokens,
|
|
8
|
+
:cache_read_input_tokens,
|
|
9
|
+
:cache_write_input_tokens,
|
|
10
|
+
:cache_write_1h_input_tokens,
|
|
11
|
+
:output_tokens,
|
|
12
|
+
:total_tokens,
|
|
13
|
+
:hidden_output_tokens
|
|
14
|
+
) do
|
|
15
|
+
def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
|
|
16
|
+
cache_write_input_tokens: 0, cache_write_1h_input_tokens: 0,
|
|
17
|
+
total_tokens: nil, hidden_output_tokens: 0)
|
|
18
|
+
input = input_tokens.to_i
|
|
19
|
+
output = output_tokens.to_i
|
|
20
|
+
cache_read = cache_read_input_tokens.to_i
|
|
21
|
+
cache_write = cache_write_input_tokens.to_i
|
|
22
|
+
cache_write_1h = cache_write_1h_input_tokens.to_i
|
|
23
|
+
calculated_total = input + cache_read + cache_write + cache_write_1h + output
|
|
24
|
+
total = total_tokens.nil? ? calculated_total : [total_tokens.to_i, calculated_total].max
|
|
25
|
+
|
|
26
|
+
new(
|
|
27
|
+
input_tokens: input,
|
|
28
|
+
cache_read_input_tokens: cache_read,
|
|
29
|
+
cache_write_input_tokens: cache_write,
|
|
30
|
+
cache_write_1h_input_tokens: cache_write_1h,
|
|
31
|
+
output_tokens: output,
|
|
32
|
+
total_tokens: total,
|
|
33
|
+
hidden_output_tokens: hidden_output_tokens.to_i
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.from_hash(attributes)
|
|
38
|
+
attributes = attributes.to_h.symbolize_keys
|
|
39
|
+
values = TokenUsage::COMPONENT_TOKEN_KEYS.to_h { |key| [key, attributes[key]] }
|
|
40
|
+
build(
|
|
41
|
+
**values,
|
|
42
|
+
total_tokens: attributes[:total_tokens]
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def price_quantities
|
|
47
|
+
{
|
|
48
|
+
input: input_tokens,
|
|
49
|
+
cache_read_input: cache_read_input_tokens,
|
|
50
|
+
cache_write_input: cache_write_input_tokens,
|
|
51
|
+
cache_write_1h_input: cache_write_1h_input_tokens,
|
|
52
|
+
output: output_tokens
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stored_attributes
|
|
57
|
+
to_h.slice(*self.class::STORED_KEYS)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
super.compact
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
TokenUsage::STORED_KEYS = TokenUsage.members.freeze
|
|
66
|
+
TokenUsage::COMPONENT_TOKEN_KEYS = (TokenUsage.members - %i[total_tokens]).freeze
|
|
67
|
+
end
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
|
|
5
|
-
require_relative "
|
|
6
|
+
require_relative "ingestion"
|
|
7
|
+
require_relative "ledger"
|
|
8
|
+
require_relative "pricing"
|
|
6
9
|
|
|
7
10
|
module LlmCostTracker
|
|
8
11
|
class Tracker
|
|
9
12
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
10
13
|
|
|
11
14
|
USAGE_SOURCES = %i[response stream_final sdk_response ruby_llm manual unknown].freeze
|
|
15
|
+
TRACKING_METADATA_KEYS = (TokenUsage.members.map(&:to_s) + %w[pricing_mode provider_response_id]).freeze
|
|
12
16
|
|
|
13
17
|
class << self
|
|
14
18
|
def enforce_budget!
|
|
@@ -17,99 +21,65 @@ module LlmCostTracker
|
|
|
17
21
|
Budget.enforce!
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
def record(
|
|
21
|
-
usage_source: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
|
|
24
|
+
def record(capture:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
|
|
22
25
|
return unless LlmCostTracker.configuration.enabled
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
|
|
28
|
+
cost_data = Pricing.cost_for(
|
|
29
|
+
provider: capture.provider,
|
|
30
|
+
model: capture.model,
|
|
31
|
+
token_usage: capture.token_usage,
|
|
32
|
+
pricing_mode: pricing_mode
|
|
33
|
+
)
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
Pricing::Unknown.handle!(capture.model) unless cost_data
|
|
29
36
|
|
|
30
37
|
event = build_event(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
usage: usage,
|
|
38
|
+
capture: capture,
|
|
39
|
+
pricing_mode: pricing_mode,
|
|
34
40
|
cost_data: cost_data,
|
|
35
41
|
metadata: metadata,
|
|
36
42
|
latency_ms: latency_ms,
|
|
37
|
-
|
|
38
|
-
usage_source: usage_source,
|
|
39
|
-
provider_response_id: provider_response_id
|
|
43
|
+
context_tags: context_tags
|
|
40
44
|
)
|
|
41
45
|
|
|
42
46
|
ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
Budget.check!(event)
|
|
48
|
+
Ingestion::Inbox.save(event)
|
|
49
|
+
Budget.check!(event)
|
|
46
50
|
|
|
47
51
|
event
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
private
|
|
51
55
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def cost_for_usage(provider, model, usage)
|
|
63
|
-
Pricing.cost_for(
|
|
64
|
-
provider: provider,
|
|
65
|
-
model: model,
|
|
66
|
-
input_tokens: usage[:input_tokens],
|
|
67
|
-
output_tokens: usage[:output_tokens],
|
|
68
|
-
cache_read_input_tokens: usage[:cache_read_input_tokens],
|
|
69
|
-
cache_write_input_tokens: usage[:cache_write_input_tokens],
|
|
70
|
-
pricing_mode: usage[:pricing_mode]
|
|
71
|
-
)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def normalize_model(value) = value.to_s.strip.then { |model| model.empty? ? ParsedUsage::UNKNOWN_MODEL : model }
|
|
56
|
+
def build_event(capture:, pricing_mode:, cost_data:, metadata:, latency_ms:, context_tags:)
|
|
57
|
+
usage_source = if capture.usage_source.nil?
|
|
58
|
+
nil
|
|
59
|
+
else
|
|
60
|
+
symbol = capture.usage_source.to_sym
|
|
61
|
+
USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
|
|
62
|
+
end
|
|
63
|
+
tags = metadata.to_h.reject { |key, _value| TRACKING_METADATA_KEYS.include?(key.to_s) }
|
|
64
|
+
context_tags = context_tags.nil? ? LlmCostTracker::Tags::Context.tags : context_tags.to_h
|
|
75
65
|
|
|
76
|
-
def build_event(provider:, model:, usage:, cost_data:, metadata:, latency_ms:, stream:, usage_source:,
|
|
77
|
-
provider_response_id:)
|
|
78
66
|
Event.new(
|
|
79
67
|
event_id: SecureRandom.uuid,
|
|
80
|
-
provider: provider,
|
|
81
|
-
model: model,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
total_tokens: usage[:total_tokens],
|
|
85
|
-
cache_read_input_tokens: usage[:cache_read_input_tokens],
|
|
86
|
-
cache_write_input_tokens: usage[:cache_write_input_tokens],
|
|
87
|
-
hidden_output_tokens: usage[:hidden_output_tokens],
|
|
88
|
-
pricing_mode: usage[:pricing_mode],
|
|
68
|
+
provider: capture.provider,
|
|
69
|
+
model: capture.model,
|
|
70
|
+
token_usage: capture.token_usage,
|
|
71
|
+
pricing_mode: pricing_mode,
|
|
89
72
|
cost: cost_data,
|
|
90
|
-
tags:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
73
|
+
tags: LlmCostTracker::Tags::Sanitizer.call(
|
|
74
|
+
context_tags.merge(tags)
|
|
75
|
+
).freeze,
|
|
76
|
+
latency_ms: latency_ms.nil? ? nil : [latency_ms.to_i, 0].max,
|
|
77
|
+
stream: capture.stream ? true : false,
|
|
78
|
+
usage_source: usage_source,
|
|
79
|
+
provider_response_id: capture.provider_response_id.to_s.presence,
|
|
95
80
|
tracked_at: Time.now.utc
|
|
96
81
|
)
|
|
97
82
|
end
|
|
98
|
-
|
|
99
|
-
def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
|
|
100
|
-
|
|
101
|
-
def sanitized_tags(metadata)
|
|
102
|
-
LlmCostTracker::TagSanitizer.call(LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)))
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def normalized_usage_source(value)
|
|
106
|
-
return nil if value.nil?
|
|
107
|
-
|
|
108
|
-
symbol = value.to_sym
|
|
109
|
-
USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def normalized_provider_response_id(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
113
83
|
end
|
|
114
84
|
end
|
|
115
85
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
|
|
5
|
+
require_relative "pricing"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
UsageCapture = Data.define(
|
|
9
|
+
:provider,
|
|
10
|
+
:model,
|
|
11
|
+
:token_usage,
|
|
12
|
+
:stream,
|
|
13
|
+
:usage_source,
|
|
14
|
+
:provider_response_id,
|
|
15
|
+
:pricing_mode
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class UsageCapture
|
|
19
|
+
UNKNOWN_MODEL = "unknown"
|
|
20
|
+
|
|
21
|
+
def self.build(**attributes)
|
|
22
|
+
new(
|
|
23
|
+
provider: attributes.fetch(:provider).to_s,
|
|
24
|
+
model: attributes.fetch(:model).to_s.strip.presence || UNKNOWN_MODEL,
|
|
25
|
+
token_usage: attributes.fetch(:token_usage),
|
|
26
|
+
stream: attributes[:stream] || false,
|
|
27
|
+
usage_source: attributes[:usage_source],
|
|
28
|
+
provider_response_id: attributes[:provider_response_id],
|
|
29
|
+
pricing_mode: Pricing.normalize_mode(attributes[:pricing_mode])
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
super.compact
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rails"
|
|
3
4
|
require "active_support"
|
|
5
|
+
require "active_support/core_ext/object/blank"
|
|
6
|
+
require "active_support/core_ext/object/deep_dup"
|
|
7
|
+
require "active_support/core_ext/object/try"
|
|
8
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
9
|
+
require "active_support/core_ext/string/inflections"
|
|
4
10
|
require "active_support/notifications"
|
|
5
|
-
require "monitor"
|
|
6
11
|
|
|
7
12
|
require_relative "llm_cost_tracker/version"
|
|
8
13
|
require_relative "llm_cost_tracker/configuration"
|
|
9
14
|
require_relative "llm_cost_tracker/errors"
|
|
10
15
|
require_relative "llm_cost_tracker/logging"
|
|
11
|
-
require_relative "llm_cost_tracker/
|
|
12
|
-
require_relative "llm_cost_tracker/
|
|
13
|
-
require_relative "llm_cost_tracker/
|
|
16
|
+
require_relative "llm_cost_tracker/tags/key"
|
|
17
|
+
require_relative "llm_cost_tracker/tags/context"
|
|
18
|
+
require_relative "llm_cost_tracker/tags/sanitizer"
|
|
19
|
+
require_relative "llm_cost_tracker/token_usage"
|
|
14
20
|
require_relative "llm_cost_tracker/event"
|
|
15
|
-
require_relative "llm_cost_tracker/parsed_usage"
|
|
16
|
-
require_relative "llm_cost_tracker/price_registry"
|
|
17
|
-
require_relative "llm_cost_tracker/price_sync"
|
|
18
21
|
require_relative "llm_cost_tracker/pricing"
|
|
22
|
+
require_relative "llm_cost_tracker/usage_capture"
|
|
23
|
+
require_relative "llm_cost_tracker/pricing/sync"
|
|
19
24
|
require_relative "llm_cost_tracker/parsers/base"
|
|
20
25
|
require_relative "llm_cost_tracker/parsers/openai_usage"
|
|
21
26
|
require_relative "llm_cost_tracker/parsers/openai"
|
|
@@ -23,87 +28,59 @@ require_relative "llm_cost_tracker/parsers/openai_compatible"
|
|
|
23
28
|
require_relative "llm_cost_tracker/parsers/anthropic"
|
|
24
29
|
require_relative "llm_cost_tracker/parsers/gemini"
|
|
25
30
|
require_relative "llm_cost_tracker/parsers/sse"
|
|
26
|
-
require_relative "llm_cost_tracker/parsers
|
|
31
|
+
require_relative "llm_cost_tracker/parsers"
|
|
27
32
|
require_relative "llm_cost_tracker/middleware/faraday"
|
|
28
|
-
require_relative "llm_cost_tracker/integrations
|
|
33
|
+
require_relative "llm_cost_tracker/integrations"
|
|
29
34
|
require_relative "llm_cost_tracker/budget"
|
|
30
|
-
require_relative "llm_cost_tracker/
|
|
31
|
-
require_relative "llm_cost_tracker/
|
|
32
|
-
require_relative "llm_cost_tracker/
|
|
33
|
-
require_relative "llm_cost_tracker/tag_sanitizer"
|
|
34
|
-
require_relative "llm_cost_tracker/active_record_adapter"
|
|
35
|
-
require_relative "llm_cost_tracker/tags_column"
|
|
36
|
-
require_relative "llm_cost_tracker/tag_key"
|
|
37
|
-
require_relative "llm_cost_tracker/tag_sql"
|
|
38
|
-
require_relative "llm_cost_tracker/tag_query"
|
|
39
|
-
require_relative "llm_cost_tracker/tag_accessors"
|
|
40
|
-
require_relative "llm_cost_tracker/llm_api_call_metrics"
|
|
35
|
+
require_relative "llm_cost_tracker/pricing/unknown"
|
|
36
|
+
require_relative "llm_cost_tracker/ledger"
|
|
37
|
+
require_relative "llm_cost_tracker/ingestion"
|
|
41
38
|
require_relative "llm_cost_tracker/tracker"
|
|
42
39
|
require_relative "llm_cost_tracker/retention"
|
|
43
|
-
require_relative "llm_cost_tracker/report_data"
|
|
44
|
-
require_relative "llm_cost_tracker/report_formatter"
|
|
45
40
|
require_relative "llm_cost_tracker/report"
|
|
46
41
|
require_relative "llm_cost_tracker/doctor"
|
|
47
|
-
require_relative "llm_cost_tracker/capture_verifier"
|
|
42
|
+
require_relative "llm_cost_tracker/doctor/capture_verifier"
|
|
48
43
|
|
|
49
44
|
module LlmCostTracker
|
|
50
|
-
|
|
45
|
+
@configuration = Configuration.new
|
|
51
46
|
|
|
52
47
|
class << self
|
|
53
|
-
|
|
54
|
-
CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def configuration_generation
|
|
58
|
-
CONFIGURATION_MUTEX.synchronize { @configuration_generation ||= 0 }
|
|
59
|
-
end
|
|
48
|
+
attr_reader :configuration
|
|
60
49
|
|
|
61
50
|
def configure
|
|
62
|
-
config =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
current
|
|
71
|
-
end
|
|
72
|
-
Integrations::Registry.install!
|
|
51
|
+
config = configuration
|
|
52
|
+
raise Error, "LlmCostTracker is already configured" if config.finalized?
|
|
53
|
+
|
|
54
|
+
yield(config)
|
|
55
|
+
config.openai_compatible_providers = config.openai_compatible_providers.dup
|
|
56
|
+
config.finalize!
|
|
57
|
+
Pricing::Lookup.reset!
|
|
58
|
+
Integrations.install!
|
|
73
59
|
config
|
|
74
60
|
end
|
|
75
61
|
|
|
76
62
|
def reset_configuration!
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
UnknownPricing.reset! if defined?(UnknownPricing)
|
|
84
|
-
Storage::ActiveRecordStore.reset! if defined?(Storage::ActiveRecordStore)
|
|
85
|
-
Storage::ActiveRecordInbox.reset! if defined?(Storage::ActiveRecordInbox)
|
|
86
|
-
Storage::ActiveRecordIngestor.reset! if defined?(Storage::ActiveRecordIngestor)
|
|
87
|
-
TagContext.clear! if defined?(TagContext)
|
|
63
|
+
Ingestion::Worker.shutdown!(drain: false)
|
|
64
|
+
@configuration = Configuration.new
|
|
65
|
+
Pricing::Lookup.reset!
|
|
66
|
+
Pricing::Unknown.reset!
|
|
67
|
+
Ingestion::Worker.reset!
|
|
68
|
+
Tags::Context.clear!
|
|
88
69
|
end
|
|
89
70
|
|
|
90
71
|
def flush!(timeout: nil)
|
|
91
|
-
return true unless defined?(Storage::ActiveRecordIngestor)
|
|
92
|
-
|
|
93
72
|
if timeout
|
|
94
|
-
|
|
73
|
+
Ingestion::Worker.flush!(timeout: timeout)
|
|
95
74
|
else
|
|
96
|
-
|
|
75
|
+
Ingestion::Worker.flush!
|
|
97
76
|
end
|
|
98
77
|
end
|
|
99
78
|
|
|
100
79
|
def shutdown!(timeout: nil, drain: true)
|
|
101
|
-
return true unless defined?(Storage::ActiveRecordIngestor)
|
|
102
|
-
|
|
103
80
|
if timeout
|
|
104
|
-
|
|
81
|
+
Ingestion::Worker.shutdown!(timeout: timeout, drain: drain)
|
|
105
82
|
else
|
|
106
|
-
|
|
83
|
+
Ingestion::Worker.shutdown!(drain: drain)
|
|
107
84
|
end
|
|
108
85
|
end
|
|
109
86
|
|
|
@@ -113,21 +90,24 @@ module LlmCostTracker
|
|
|
113
90
|
|
|
114
91
|
def with_tags(tags = nil, **kwargs, &)
|
|
115
92
|
merged = (tags || {}).to_h.merge(kwargs)
|
|
116
|
-
|
|
93
|
+
Tags::Context.with(merged, &)
|
|
117
94
|
end
|
|
118
95
|
|
|
119
96
|
def track(provider:, input_tokens:, output_tokens:, model: nil, latency_ms: nil, stream: false,
|
|
120
97
|
usage_source: :manual, enforce_budget: false, provider_response_id: nil, pricing_mode: nil, **metadata)
|
|
121
98
|
enforce_budget! if enforce_budget
|
|
99
|
+
token_usage = TokenUsage.from_hash(metadata.merge(input_tokens: input_tokens, output_tokens: output_tokens))
|
|
100
|
+
|
|
122
101
|
Tracker.record(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
102
|
+
capture: UsageCapture.build(
|
|
103
|
+
provider: provider,
|
|
104
|
+
model: model,
|
|
105
|
+
token_usage: token_usage,
|
|
106
|
+
stream: stream,
|
|
107
|
+
usage_source: usage_source,
|
|
108
|
+
provider_response_id: provider_response_id
|
|
109
|
+
),
|
|
127
110
|
latency_ms: latency_ms,
|
|
128
|
-
stream: stream,
|
|
129
|
-
usage_source: usage_source,
|
|
130
|
-
provider_response_id: provider_response_id,
|
|
131
111
|
pricing_mode: pricing_mode,
|
|
132
112
|
metadata: metadata
|
|
133
113
|
)
|
|
@@ -135,9 +115,9 @@ module LlmCostTracker
|
|
|
135
115
|
|
|
136
116
|
def track_stream(provider:, model: nil, latency_ms: nil, enforce_budget: false, provider_response_id: nil,
|
|
137
117
|
pricing_mode: nil, **metadata)
|
|
138
|
-
require_relative "llm_cost_tracker/stream_collector"
|
|
118
|
+
require_relative "llm_cost_tracker/capture/stream_collector"
|
|
139
119
|
enforce_budget! if enforce_budget
|
|
140
|
-
collector = StreamCollector.new(
|
|
120
|
+
collector = Capture::StreamCollector.new(
|
|
141
121
|
provider: provider.to_s,
|
|
142
122
|
model: model,
|
|
143
123
|
latency_ms: latency_ms,
|
|
@@ -154,12 +134,10 @@ module LlmCostTracker
|
|
|
154
134
|
end
|
|
155
135
|
end
|
|
156
136
|
|
|
157
|
-
require_relative "llm_cost_tracker/railtie"
|
|
137
|
+
require_relative "llm_cost_tracker/railtie"
|
|
158
138
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
)
|
|
163
|
-
end
|
|
139
|
+
Faraday::Middleware.register_middleware(
|
|
140
|
+
llm_cost_tracker: LlmCostTracker::Middleware::Faraday
|
|
141
|
+
)
|
|
164
142
|
|
|
165
|
-
at_exit { LlmCostTracker.shutdown!(drain: false)
|
|
143
|
+
at_exit { LlmCostTracker.shutdown!(drain: false) }
|