llm_cost_tracker 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +72 -1
- data/README.md +58 -221
- data/app/assets/llm_cost_tracker/application.css +218 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
- data/app/models/llm_cost_tracker/call.rb +169 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
- data/app/models/llm_cost_tracker/call_tag.rb +16 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
- data/lib/llm_cost_tracker/billing/components.rb +53 -0
- data/lib/llm_cost_tracker/billing/components.yml +117 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +23 -35
- data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
- data/lib/llm_cost_tracker/configuration.rb +36 -19
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
- data/lib/llm_cost_tracker/doctor.rb +43 -45
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +10 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
- data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
- data/lib/llm_cost_tracker/ingestion.rb +28 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
- data/lib/llm_cost_tracker/integrations/base.rb +36 -29
- data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
- data/lib/llm_cost_tracker/integrations.rb +2 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +110 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
- data/lib/llm_cost_tracker/ledger.rb +4 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
- data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
- data/lib/llm_cost_tracker/parsers/base.rb +8 -3
- data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +105 -20
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
- data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +190 -26
- data/lib/llm_cost_tracker/railtie.rb +0 -8
- data/lib/llm_cost_tracker/report/data.rb +16 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +8 -8
- data/lib/llm_cost_tracker/tags/context.rb +2 -4
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +56 -42
- data/lib/llm_cost_tracker/tracker.rb +67 -24
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +36 -35
- data/lib/tasks/llm_cost_tracker.rake +22 -17
- metadata +36 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -1,67 +1,81 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "billing/components"
|
|
4
|
+
require_relative "logging"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
7
|
+
KNOWN_TOKEN_KEYS = (
|
|
8
|
+
Billing::Components::TOKEN_PRICED.map(&:key) + %i[total hidden_output]
|
|
9
|
+
).freeze
|
|
10
|
+
|
|
6
11
|
TokenUsage = Data.define(
|
|
7
12
|
:input_tokens,
|
|
8
13
|
:cache_read_input_tokens,
|
|
9
14
|
:cache_write_input_tokens,
|
|
10
|
-
:
|
|
15
|
+
:cache_write_extended_input_tokens,
|
|
16
|
+
:audio_input_tokens,
|
|
11
17
|
:output_tokens,
|
|
18
|
+
:audio_output_tokens,
|
|
12
19
|
:total_tokens,
|
|
13
20
|
:hidden_output_tokens
|
|
14
21
|
) do
|
|
15
|
-
def self.
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
22
|
+
def self.build_from_tokens(tokens)
|
|
23
|
+
return tokens if tokens.is_a?(self)
|
|
24
|
+
raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
output_tokens: output,
|
|
32
|
-
total_tokens: total,
|
|
33
|
-
hidden_output_tokens: hidden_output_tokens.to_i
|
|
34
|
-
)
|
|
35
|
-
end
|
|
26
|
+
values = tokens.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
27
|
+
warn_on_unknown_keys(values)
|
|
28
|
+
token_attributes = Billing::Components::TOKEN_PRICED.to_h do |component|
|
|
29
|
+
[component.token_key, values.fetch(component.key, 0)]
|
|
30
|
+
end
|
|
36
31
|
|
|
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
32
|
build(
|
|
41
|
-
**
|
|
42
|
-
total_tokens:
|
|
33
|
+
**token_attributes,
|
|
34
|
+
total_tokens: values[:total],
|
|
35
|
+
hidden_output_tokens: values.fetch(:hidden_output, 0)
|
|
43
36
|
)
|
|
44
37
|
end
|
|
45
38
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
39
|
+
def self.warn_on_unknown_keys(values)
|
|
40
|
+
return if values.empty?
|
|
41
|
+
return if values.keys.intersect?(KNOWN_TOKEN_KEYS)
|
|
42
|
+
|
|
43
|
+
Logging.warn(
|
|
44
|
+
"tokens hash contains no recognized keys (#{values.keys.inspect}); " \
|
|
45
|
+
"expected one of #{KNOWN_TOKEN_KEYS.inspect}. Did you pass a raw provider response?"
|
|
46
|
+
)
|
|
54
47
|
end
|
|
55
48
|
|
|
56
|
-
def
|
|
57
|
-
|
|
49
|
+
def self.non_negative_int(value)
|
|
50
|
+
[value.to_i, 0].max
|
|
58
51
|
end
|
|
59
52
|
|
|
60
|
-
def
|
|
61
|
-
|
|
53
|
+
def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
|
|
54
|
+
cache_write_input_tokens: 0, cache_write_extended_input_tokens: 0,
|
|
55
|
+
audio_input_tokens: 0, audio_output_tokens: 0,
|
|
56
|
+
total_tokens: nil, hidden_output_tokens: 0)
|
|
57
|
+
input = non_negative_int(input_tokens)
|
|
58
|
+
output = non_negative_int(output_tokens)
|
|
59
|
+
cache_read = non_negative_int(cache_read_input_tokens)
|
|
60
|
+
cache_write = non_negative_int(cache_write_input_tokens)
|
|
61
|
+
cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
|
|
62
|
+
audio_input = non_negative_int(audio_input_tokens)
|
|
63
|
+
audio_output = non_negative_int(audio_output_tokens)
|
|
64
|
+
hidden_output = non_negative_int(hidden_output_tokens)
|
|
65
|
+
calculated_total = input + cache_read + cache_write + cache_write_extended + audio_input + output + audio_output
|
|
66
|
+
total = total_tokens.nil? ? calculated_total : [non_negative_int(total_tokens), calculated_total].max
|
|
67
|
+
|
|
68
|
+
new(
|
|
69
|
+
input_tokens: input,
|
|
70
|
+
cache_read_input_tokens: cache_read,
|
|
71
|
+
cache_write_input_tokens: cache_write,
|
|
72
|
+
cache_write_extended_input_tokens: cache_write_extended,
|
|
73
|
+
audio_input_tokens: audio_input,
|
|
74
|
+
output_tokens: output,
|
|
75
|
+
audio_output_tokens: audio_output,
|
|
76
|
+
total_tokens: total,
|
|
77
|
+
hidden_output_tokens: hidden_output
|
|
78
|
+
)
|
|
62
79
|
end
|
|
63
80
|
end
|
|
64
|
-
|
|
65
|
-
TokenUsage::STORED_KEYS = TokenUsage.members.freeze
|
|
66
|
-
TokenUsage::COMPONENT_TOKEN_KEYS = (TokenUsage.members - %i[total_tokens]).freeze
|
|
67
81
|
end
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "bigdecimal"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
|
|
6
7
|
require_relative "ingestion"
|
|
7
8
|
require_relative "ledger"
|
|
8
9
|
require_relative "pricing"
|
|
10
|
+
require_relative "billing/cost_status"
|
|
9
11
|
|
|
10
12
|
module LlmCostTracker
|
|
11
13
|
class Tracker
|
|
12
14
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
13
15
|
|
|
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
|
|
16
|
-
|
|
17
16
|
class << self
|
|
18
17
|
def enforce_budget!
|
|
19
18
|
return unless LlmCostTracker.configuration.enabled
|
|
@@ -25,19 +24,20 @@ module LlmCostTracker
|
|
|
25
24
|
return unless LlmCostTracker.configuration.enabled
|
|
26
25
|
|
|
27
26
|
pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
|
|
28
|
-
cost_data = Pricing.
|
|
27
|
+
cost_data, pricing_snapshot = Pricing.cost_and_snapshot_for(
|
|
29
28
|
provider: capture.provider,
|
|
30
29
|
model: capture.model,
|
|
31
|
-
|
|
30
|
+
tokens: capture.token_usage,
|
|
32
31
|
pricing_mode: pricing_mode
|
|
33
32
|
)
|
|
34
33
|
|
|
35
|
-
Pricing::Unknown.handle!(capture.model)
|
|
34
|
+
Pricing::Unknown.handle!(capture.model) if cost_data.nil? && capture.token_usage.total_tokens.positive?
|
|
36
35
|
|
|
37
36
|
event = build_event(
|
|
38
37
|
capture: capture,
|
|
39
38
|
pricing_mode: pricing_mode,
|
|
40
39
|
cost_data: cost_data,
|
|
40
|
+
pricing_snapshot: pricing_snapshot,
|
|
41
41
|
metadata: metadata,
|
|
42
42
|
latency_ms: latency_ms,
|
|
43
43
|
context_tags: context_tags
|
|
@@ -53,15 +53,32 @@ module LlmCostTracker
|
|
|
53
53
|
|
|
54
54
|
private
|
|
55
55
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
def token_pricing_partial?(token_usage:, cost_data:)
|
|
57
|
+
return false unless cost_data
|
|
58
|
+
|
|
59
|
+
Billing::Components::TOKEN_PRICED.any? do |component|
|
|
60
|
+
token_usage.public_send(component.token_key).positive? && cost_data[component.cost_key].nil?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# rubocop:disable Metrics/MethodLength
|
|
65
|
+
def build_event(capture:, pricing_mode:, cost_data:, pricing_snapshot:, metadata:, latency_ms:, context_tags:)
|
|
66
|
+
context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
|
|
67
|
+
line_items, = Pricing.price_line_items(
|
|
68
|
+
provider: capture.provider,
|
|
69
|
+
model: capture.model,
|
|
70
|
+
line_items: capture.line_items,
|
|
71
|
+
pricing_mode: pricing_mode
|
|
72
|
+
)
|
|
73
|
+
cost = cost_with_service_lines(cost_data, line_items)
|
|
74
|
+
cost_status = Billing::CostStatus.call(
|
|
75
|
+
token_usage: capture.token_usage,
|
|
76
|
+
usage_source: capture.usage_source,
|
|
77
|
+
token_cost: cost_data,
|
|
78
|
+
token_pricing_partial: token_pricing_partial?(token_usage: capture.token_usage, cost_data: cost_data),
|
|
79
|
+
service_line_items: line_items.reject(&:token?),
|
|
80
|
+
total_cost: cost&.fetch(:total_cost, nil)
|
|
81
|
+
)
|
|
65
82
|
|
|
66
83
|
Event.new(
|
|
67
84
|
event_id: SecureRandom.uuid,
|
|
@@ -69,17 +86,43 @@ module LlmCostTracker
|
|
|
69
86
|
model: capture.model,
|
|
70
87
|
token_usage: capture.token_usage,
|
|
71
88
|
pricing_mode: pricing_mode,
|
|
72
|
-
cost:
|
|
73
|
-
tags: LlmCostTracker::Tags::Sanitizer.call(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
cost: cost,
|
|
90
|
+
tags: LlmCostTracker::Tags::Sanitizer.call(context_tags.merge(metadata.to_h)).freeze,
|
|
91
|
+
latency_ms: finite_latency_ms(latency_ms),
|
|
92
|
+
stream: capture.stream,
|
|
93
|
+
usage_source: capture.usage_source,
|
|
94
|
+
provider_response_id: capture.provider_response_id,
|
|
95
|
+
provider_project_id: capture.provider_project_id,
|
|
96
|
+
provider_api_key_id: capture.provider_api_key_id,
|
|
97
|
+
provider_workspace_id: capture.provider_workspace_id,
|
|
98
|
+
batch: capture.batch,
|
|
99
|
+
tracked_at: Time.now.utc,
|
|
100
|
+
cost_status: cost_status,
|
|
101
|
+
pricing_snapshot: pricing_snapshot,
|
|
102
|
+
line_items: line_items
|
|
81
103
|
)
|
|
82
104
|
end
|
|
105
|
+
# rubocop:enable Metrics/MethodLength
|
|
106
|
+
|
|
107
|
+
def finite_latency_ms(latency_ms)
|
|
108
|
+
return nil if latency_ms.nil?
|
|
109
|
+
|
|
110
|
+
Integer(latency_ms).clamp(0, (1 << 31) - 1)
|
|
111
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cost_with_service_lines(cost_data, line_items)
|
|
116
|
+
service_lines = line_items.reject(&:token?)
|
|
117
|
+
return cost_data if service_lines.empty?
|
|
118
|
+
return cost_data if service_lines.none?(&:priced?)
|
|
119
|
+
|
|
120
|
+
service_total = service_lines.sum(BigDecimal("0"), &:cost_value)
|
|
121
|
+
cost = cost_data ? cost_data.dup : {}
|
|
122
|
+
base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
|
|
123
|
+
cost[:total_cost] = (base_total + service_total).round(8).to_f
|
|
124
|
+
cost
|
|
125
|
+
end
|
|
83
126
|
end
|
|
84
127
|
end
|
|
85
128
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
4
|
|
|
5
5
|
require_relative "pricing"
|
|
6
|
+
require_relative "billing/line_item"
|
|
6
7
|
|
|
7
8
|
module LlmCostTracker
|
|
8
9
|
UsageCapture = Data.define(
|
|
@@ -12,26 +13,46 @@ module LlmCostTracker
|
|
|
12
13
|
:stream,
|
|
13
14
|
:usage_source,
|
|
14
15
|
:provider_response_id,
|
|
15
|
-
:
|
|
16
|
+
:provider_project_id,
|
|
17
|
+
:provider_api_key_id,
|
|
18
|
+
:provider_workspace_id,
|
|
19
|
+
:batch,
|
|
20
|
+
:pricing_mode,
|
|
21
|
+
:line_items
|
|
16
22
|
)
|
|
17
23
|
|
|
18
24
|
class UsageCapture
|
|
19
25
|
UNKNOWN_MODEL = "unknown"
|
|
20
26
|
|
|
27
|
+
def self.batch_from_pricing_mode?(pricing_mode)
|
|
28
|
+
pricing_mode.to_s.split("_").include?("batch")
|
|
29
|
+
end
|
|
30
|
+
|
|
21
31
|
def self.build(**attributes)
|
|
32
|
+
pricing_mode = Pricing.normalize_mode(attributes[:pricing_mode])
|
|
33
|
+
batch = attributes[:batch]
|
|
34
|
+
batch = batch_from_pricing_mode?(pricing_mode) if batch.nil?
|
|
35
|
+
|
|
36
|
+
token_usage = attributes.fetch(:token_usage)
|
|
37
|
+
service_line_items = Array(attributes[:service_line_items]).map do |item|
|
|
38
|
+
item.is_a?(Billing::LineItem) ? item : Billing::LineItem.build(item)
|
|
39
|
+
end
|
|
40
|
+
line_items = attributes[:line_items] || (Billing::LineItem.from_token_usage(token_usage) + service_line_items)
|
|
41
|
+
|
|
22
42
|
new(
|
|
23
43
|
provider: attributes.fetch(:provider).to_s,
|
|
24
44
|
model: attributes.fetch(:model).to_s.strip.presence || UNKNOWN_MODEL,
|
|
25
|
-
token_usage:
|
|
45
|
+
token_usage: token_usage,
|
|
26
46
|
stream: attributes[:stream] || false,
|
|
27
47
|
usage_source: attributes[:usage_source],
|
|
28
|
-
provider_response_id: attributes[:provider_response_id],
|
|
29
|
-
|
|
48
|
+
provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
|
|
49
|
+
provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
|
|
50
|
+
provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
|
|
51
|
+
provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
|
|
52
|
+
batch: batch,
|
|
53
|
+
pricing_mode: pricing_mode,
|
|
54
|
+
line_items: line_items
|
|
30
55
|
)
|
|
31
56
|
end
|
|
32
|
-
|
|
33
|
-
def to_h
|
|
34
|
-
super.compact
|
|
35
|
-
end
|
|
36
57
|
end
|
|
37
58
|
end
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -17,6 +17,9 @@ require_relative "llm_cost_tracker/tags/key"
|
|
|
17
17
|
require_relative "llm_cost_tracker/tags/context"
|
|
18
18
|
require_relative "llm_cost_tracker/tags/sanitizer"
|
|
19
19
|
require_relative "llm_cost_tracker/token_usage"
|
|
20
|
+
require_relative "llm_cost_tracker/billing/components"
|
|
21
|
+
require_relative "llm_cost_tracker/billing/line_item"
|
|
22
|
+
require_relative "llm_cost_tracker/billing/cost_status"
|
|
20
23
|
require_relative "llm_cost_tracker/event"
|
|
21
24
|
require_relative "llm_cost_tracker/pricing"
|
|
22
25
|
require_relative "llm_cost_tracker/usage_capture"
|
|
@@ -47,14 +50,19 @@ module LlmCostTracker
|
|
|
47
50
|
class << self
|
|
48
51
|
attr_reader :configuration
|
|
49
52
|
|
|
53
|
+
def table_name_prefix
|
|
54
|
+
"llm_cost_tracker_"
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
def configure
|
|
51
58
|
config = configuration
|
|
52
59
|
raise Error, "LlmCostTracker is already configured" if config.finalized?
|
|
53
60
|
|
|
54
61
|
yield(config)
|
|
55
|
-
config.openai_compatible_providers = config.openai_compatible_providers.dup
|
|
56
62
|
config.finalize!
|
|
57
63
|
Pricing::Lookup.reset!
|
|
64
|
+
Pricing::Registry.reset!
|
|
65
|
+
Pricing::ServiceCharges.reset!
|
|
58
66
|
Integrations.install!
|
|
59
67
|
config
|
|
60
68
|
end
|
|
@@ -63,67 +71,60 @@ module LlmCostTracker
|
|
|
63
71
|
Ingestion::Worker.shutdown!(drain: false)
|
|
64
72
|
@configuration = Configuration.new
|
|
65
73
|
Pricing::Lookup.reset!
|
|
74
|
+
Pricing::Registry.reset!
|
|
75
|
+
Pricing::ServiceCharges.reset!
|
|
66
76
|
Pricing::Unknown.reset!
|
|
67
77
|
Ingestion::Worker.reset!
|
|
68
78
|
Tags::Context.clear!
|
|
69
79
|
end
|
|
70
80
|
|
|
71
|
-
def flush!(timeout: nil)
|
|
72
|
-
if timeout
|
|
73
|
-
Ingestion::Worker.flush!(timeout: timeout)
|
|
74
|
-
else
|
|
75
|
-
Ingestion::Worker.flush!
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def shutdown!(timeout: nil, drain: true)
|
|
80
|
-
if timeout
|
|
81
|
-
Ingestion::Worker.shutdown!(timeout: timeout, drain: drain)
|
|
82
|
-
else
|
|
83
|
-
Ingestion::Worker.shutdown!(drain: drain)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def enforce_budget!
|
|
88
|
-
Tracker.enforce_budget!
|
|
89
|
-
end
|
|
90
|
-
|
|
91
81
|
def with_tags(tags = nil, **kwargs, &)
|
|
92
|
-
|
|
93
|
-
Tags::Context.with(merged, &)
|
|
82
|
+
Tags::Context.with((tags || {}).merge(kwargs), &)
|
|
94
83
|
end
|
|
95
84
|
|
|
96
|
-
def track(provider:,
|
|
97
|
-
usage_source: :manual, enforce_budget: false,
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
def track(provider:, tokens:, model: nil, tags: {}, latency_ms: nil, stream: false,
|
|
86
|
+
usage_source: :manual, enforce_budget: false,
|
|
87
|
+
provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
|
|
88
|
+
provider_workspace_id: nil, batch: nil, pricing_mode: nil, service_line_items: [])
|
|
89
|
+
Tracker.enforce_budget! if enforce_budget
|
|
100
90
|
|
|
101
91
|
Tracker.record(
|
|
102
92
|
capture: UsageCapture.build(
|
|
103
93
|
provider: provider,
|
|
104
94
|
model: model,
|
|
105
|
-
token_usage:
|
|
95
|
+
token_usage: TokenUsage.build_from_tokens(tokens),
|
|
106
96
|
stream: stream,
|
|
107
97
|
usage_source: usage_source,
|
|
108
|
-
provider_response_id: provider_response_id
|
|
98
|
+
provider_response_id: provider_response_id,
|
|
99
|
+
provider_project_id: provider_project_id,
|
|
100
|
+
provider_api_key_id: provider_api_key_id,
|
|
101
|
+
provider_workspace_id: provider_workspace_id,
|
|
102
|
+
batch: batch,
|
|
103
|
+
pricing_mode: pricing_mode,
|
|
104
|
+
service_line_items: service_line_items
|
|
109
105
|
),
|
|
110
106
|
latency_ms: latency_ms,
|
|
111
107
|
pricing_mode: pricing_mode,
|
|
112
|
-
metadata:
|
|
108
|
+
metadata: tags
|
|
113
109
|
)
|
|
114
110
|
end
|
|
115
111
|
|
|
116
|
-
def track_stream(provider:, model: nil, latency_ms: nil, enforce_budget: false,
|
|
117
|
-
|
|
112
|
+
def track_stream(provider:, model: nil, tags: {}, latency_ms: nil, enforce_budget: false,
|
|
113
|
+
provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
|
|
114
|
+
provider_workspace_id: nil, batch: nil, pricing_mode: nil)
|
|
118
115
|
require_relative "llm_cost_tracker/capture/stream_collector"
|
|
119
|
-
enforce_budget! if enforce_budget
|
|
116
|
+
Tracker.enforce_budget! if enforce_budget
|
|
120
117
|
collector = Capture::StreamCollector.new(
|
|
121
118
|
provider: provider.to_s,
|
|
122
119
|
model: model,
|
|
123
120
|
latency_ms: latency_ms,
|
|
124
121
|
provider_response_id: provider_response_id,
|
|
122
|
+
provider_project_id: provider_project_id,
|
|
123
|
+
provider_api_key_id: provider_api_key_id,
|
|
124
|
+
provider_workspace_id: provider_workspace_id,
|
|
125
|
+
batch: batch,
|
|
125
126
|
pricing_mode: pricing_mode,
|
|
126
|
-
metadata:
|
|
127
|
+
metadata: tags
|
|
127
128
|
)
|
|
128
129
|
yield collector
|
|
129
130
|
collector.finish!
|
|
@@ -140,4 +141,4 @@ Faraday::Middleware.register_middleware(
|
|
|
140
141
|
llm_cost_tracker: LlmCostTracker::Middleware::Faraday
|
|
141
142
|
)
|
|
142
143
|
|
|
143
|
-
at_exit { LlmCostTracker.shutdown!(drain: false) }
|
|
144
|
+
at_exit { LlmCostTracker::Ingestion::Worker.shutdown!(drain: false) }
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "rails/generators"
|
|
5
|
+
|
|
6
|
+
require_relative "../llm_cost_tracker/generators/llm_cost_tracker/install_generator"
|
|
7
|
+
require_relative "../llm_cost_tracker/pricing/sync_change_printer"
|
|
4
8
|
|
|
5
9
|
# rubocop:disable Metrics/BlockLength
|
|
6
10
|
namespace :llm_cost_tracker do
|
|
11
|
+
desc "Install LLM Cost Tracker with dashboard and prices, migrate, and run doctor"
|
|
12
|
+
task :setup do
|
|
13
|
+
Rails::Generators.invoke("llm_cost_tracker:install", %w[--dashboard --prices])
|
|
14
|
+
Rake::Task["db:migrate"].invoke
|
|
15
|
+
Rake::Task["llm_cost_tracker:doctor"].invoke
|
|
16
|
+
end
|
|
17
|
+
|
|
7
18
|
desc "Check LLM Cost Tracker setup"
|
|
8
19
|
task :doctor do
|
|
9
20
|
Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
|
|
@@ -30,7 +41,7 @@ namespace :llm_cost_tracker do
|
|
|
30
41
|
puts LlmCostTracker::Report.generate(days: days)
|
|
31
42
|
end
|
|
32
43
|
|
|
33
|
-
desc "Delete
|
|
44
|
+
desc "Delete llm_cost_tracker_calls older than DAYS (default: 90). Use BATCH_SIZE=N to tune."
|
|
34
45
|
task prune: :environment do
|
|
35
46
|
days = (ENV["DAYS"] || 90).to_i
|
|
36
47
|
batch_size = (ENV["BATCH_SIZE"] || LlmCostTracker::Retention::DEFAULT_BATCH_SIZE).to_i
|
|
@@ -102,15 +113,7 @@ end
|
|
|
102
113
|
# rubocop:enable Metrics/BlockLength
|
|
103
114
|
|
|
104
115
|
def print_changes(changes)
|
|
105
|
-
|
|
106
|
-
return if changes.empty?
|
|
107
|
-
|
|
108
|
-
changes.each do |model, fields|
|
|
109
|
-
puts " - #{model}"
|
|
110
|
-
fields.each do |field, values|
|
|
111
|
-
puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
|
|
112
|
-
end
|
|
113
|
-
end
|
|
116
|
+
LlmCostTracker::Pricing::SyncChangePrinter.call(changes)
|
|
114
117
|
end
|
|
115
118
|
|
|
116
119
|
def price_refresh_output_path
|
|
@@ -128,13 +131,15 @@ def price_explanation_from_env
|
|
|
128
131
|
provider: provider,
|
|
129
132
|
model: model,
|
|
130
133
|
pricing_mode: ENV.fetch("PRICING_MODE", nil),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
tokens: {
|
|
135
|
+
input: ENV.fetch("INPUT_TOKENS", 1).to_i,
|
|
136
|
+
output: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
|
|
137
|
+
cache_read_input: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
|
|
138
|
+
cache_write_input: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i,
|
|
139
|
+
cache_write_extended_input: ENV.fetch("CACHE_WRITE_EXTENDED_INPUT_TOKENS", 0).to_i,
|
|
140
|
+
audio_input: ENV.fetch("AUDIO_INPUT_TOKENS", 0).to_i,
|
|
141
|
+
audio_output: ENV.fetch("AUDIO_OUTPUT_TOKENS", 0).to_i
|
|
142
|
+
}
|
|
138
143
|
)
|
|
139
144
|
end
|
|
140
145
|
|