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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "psych"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Billing
|
|
9
|
+
module Components
|
|
10
|
+
Component = Data.define(
|
|
11
|
+
:key,
|
|
12
|
+
:kind,
|
|
13
|
+
:direction,
|
|
14
|
+
:modality,
|
|
15
|
+
:cache_state,
|
|
16
|
+
:unit,
|
|
17
|
+
:category,
|
|
18
|
+
:token_key,
|
|
19
|
+
:cost_key
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
|
|
23
|
+
DEFINITIONS_PATH = File.expand_path("components.yml", __dir__)
|
|
24
|
+
|
|
25
|
+
def self.load_registry
|
|
26
|
+
Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
|
|
27
|
+
.map { |attributes| build(attributes) }
|
|
28
|
+
.freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.build(attributes)
|
|
32
|
+
missing = REQUIRED_FIELDS - attributes.keys
|
|
33
|
+
raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
|
|
34
|
+
|
|
35
|
+
Component.new(
|
|
36
|
+
key: attributes.fetch(:key).to_sym,
|
|
37
|
+
kind: attributes.fetch(:kind).to_sym,
|
|
38
|
+
direction: attributes.fetch(:direction).to_sym,
|
|
39
|
+
modality: attributes.fetch(:modality).to_sym,
|
|
40
|
+
cache_state: attributes.fetch(:cache_state).to_sym,
|
|
41
|
+
unit: attributes.fetch(:unit).to_sym,
|
|
42
|
+
category: attributes.fetch(:category).to_sym,
|
|
43
|
+
token_key: attributes[:token_key]&.to_sym,
|
|
44
|
+
cost_key: attributes[:cost_key]&.to_sym
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
REGISTRY = load_registry
|
|
49
|
+
BY_KEY = REGISTRY.to_h { |component| [component.key, component] }.freeze
|
|
50
|
+
TOKEN_PRICED = REGISTRY.select { |component| component.token_key && component.cost_key }.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
- key: input
|
|
2
|
+
kind: text_token
|
|
3
|
+
direction: input
|
|
4
|
+
modality: text
|
|
5
|
+
cache_state: none
|
|
6
|
+
unit: token
|
|
7
|
+
category: token
|
|
8
|
+
token_key: input_tokens
|
|
9
|
+
cost_key: input_cost
|
|
10
|
+
|
|
11
|
+
- key: cache_read_input
|
|
12
|
+
kind: text_token
|
|
13
|
+
direction: input
|
|
14
|
+
modality: text
|
|
15
|
+
cache_state: read
|
|
16
|
+
unit: token
|
|
17
|
+
category: token
|
|
18
|
+
token_key: cache_read_input_tokens
|
|
19
|
+
cost_key: cache_read_input_cost
|
|
20
|
+
|
|
21
|
+
- key: cache_write_input
|
|
22
|
+
kind: text_token
|
|
23
|
+
direction: input
|
|
24
|
+
modality: text
|
|
25
|
+
cache_state: write_default
|
|
26
|
+
unit: token
|
|
27
|
+
category: token
|
|
28
|
+
token_key: cache_write_input_tokens
|
|
29
|
+
cost_key: cache_write_input_cost
|
|
30
|
+
|
|
31
|
+
- key: cache_write_extended_input
|
|
32
|
+
kind: text_token
|
|
33
|
+
direction: input
|
|
34
|
+
modality: text
|
|
35
|
+
cache_state: write_extended
|
|
36
|
+
unit: token
|
|
37
|
+
category: token
|
|
38
|
+
token_key: cache_write_extended_input_tokens
|
|
39
|
+
cost_key: cache_write_extended_input_cost
|
|
40
|
+
|
|
41
|
+
- key: output
|
|
42
|
+
kind: text_token
|
|
43
|
+
direction: output
|
|
44
|
+
modality: text
|
|
45
|
+
cache_state: none
|
|
46
|
+
unit: token
|
|
47
|
+
category: token
|
|
48
|
+
token_key: output_tokens
|
|
49
|
+
cost_key: output_cost
|
|
50
|
+
|
|
51
|
+
- key: audio_input
|
|
52
|
+
kind: audio_token
|
|
53
|
+
direction: input
|
|
54
|
+
modality: audio
|
|
55
|
+
cache_state: none
|
|
56
|
+
unit: token
|
|
57
|
+
category: token
|
|
58
|
+
token_key: audio_input_tokens
|
|
59
|
+
cost_key: audio_input_cost
|
|
60
|
+
|
|
61
|
+
- key: audio_output
|
|
62
|
+
kind: audio_token
|
|
63
|
+
direction: output
|
|
64
|
+
modality: audio
|
|
65
|
+
cache_state: none
|
|
66
|
+
unit: token
|
|
67
|
+
category: token
|
|
68
|
+
token_key: audio_output_tokens
|
|
69
|
+
cost_key: audio_output_cost
|
|
70
|
+
|
|
71
|
+
- key: web_search_request
|
|
72
|
+
kind: web_search_request
|
|
73
|
+
direction: neither
|
|
74
|
+
modality: text
|
|
75
|
+
cache_state: none
|
|
76
|
+
unit: request
|
|
77
|
+
category: tool
|
|
78
|
+
|
|
79
|
+
- key: file_search_call
|
|
80
|
+
kind: file_search_call
|
|
81
|
+
direction: neither
|
|
82
|
+
modality: text
|
|
83
|
+
cache_state: none
|
|
84
|
+
unit: request
|
|
85
|
+
category: tool
|
|
86
|
+
|
|
87
|
+
- key: container_session
|
|
88
|
+
kind: container_session
|
|
89
|
+
direction: neither
|
|
90
|
+
modality: none
|
|
91
|
+
cache_state: none
|
|
92
|
+
unit: session
|
|
93
|
+
category: runtime
|
|
94
|
+
|
|
95
|
+
- key: code_execution_request
|
|
96
|
+
kind: code_execution_request
|
|
97
|
+
direction: neither
|
|
98
|
+
modality: none
|
|
99
|
+
cache_state: none
|
|
100
|
+
unit: request
|
|
101
|
+
category: runtime
|
|
102
|
+
|
|
103
|
+
- key: code_execution_hour
|
|
104
|
+
kind: code_execution_hour
|
|
105
|
+
direction: neither
|
|
106
|
+
modality: none
|
|
107
|
+
cache_state: none
|
|
108
|
+
unit: hour
|
|
109
|
+
category: runtime
|
|
110
|
+
|
|
111
|
+
- key: grounding_request
|
|
112
|
+
kind: grounding_request
|
|
113
|
+
direction: neither
|
|
114
|
+
modality: text
|
|
115
|
+
cache_state: none
|
|
116
|
+
unit: request
|
|
117
|
+
category: tool
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "components"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Billing
|
|
7
|
+
module CostStatus
|
|
8
|
+
COMPLETE = "complete"
|
|
9
|
+
FREE = "free"
|
|
10
|
+
PARTIAL = "partial"
|
|
11
|
+
UNKNOWN = "unknown"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
15
|
+
def call(token_usage:, usage_source:, token_cost:, service_line_items:, total_cost:,
|
|
16
|
+
token_pricing_partial: false)
|
|
17
|
+
return UNKNOWN if usage_source == :unknown
|
|
18
|
+
|
|
19
|
+
token_billable = Components::TOKEN_PRICED.any? do |component|
|
|
20
|
+
token_usage.public_send(component.token_key).positive?
|
|
21
|
+
end
|
|
22
|
+
service_billable = false
|
|
23
|
+
service_priced = false
|
|
24
|
+
service_unpriced = false
|
|
25
|
+
service_line_items.each do |line_item|
|
|
26
|
+
next unless line_item.billable?
|
|
27
|
+
|
|
28
|
+
service_billable = true
|
|
29
|
+
service_priced ||= line_item.priced?
|
|
30
|
+
service_unpriced ||= line_item.unpriced?
|
|
31
|
+
break if service_priced && service_unpriced
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
|
|
35
|
+
unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
|
|
36
|
+
return UNKNOWN if unpriced && !priced
|
|
37
|
+
return PARTIAL if unpriced
|
|
38
|
+
|
|
39
|
+
total_cost.nil? || total_cost.zero? ? FREE : COMPLETE
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "components"
|
|
6
|
+
require_relative "cost_status"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Billing
|
|
10
|
+
LineItem = Data.define(
|
|
11
|
+
:kind,
|
|
12
|
+
:direction,
|
|
13
|
+
:modality,
|
|
14
|
+
:cache_state,
|
|
15
|
+
:quantity,
|
|
16
|
+
:unit,
|
|
17
|
+
:rate_amount,
|
|
18
|
+
:rate_quantity,
|
|
19
|
+
:cost,
|
|
20
|
+
:currency,
|
|
21
|
+
:cost_status,
|
|
22
|
+
:pricing_basis,
|
|
23
|
+
:price_key,
|
|
24
|
+
:price_source,
|
|
25
|
+
:price_source_version,
|
|
26
|
+
:provider_field,
|
|
27
|
+
:provider_item_id,
|
|
28
|
+
:details
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class LineItem
|
|
32
|
+
USD = "USD"
|
|
33
|
+
OPTIONAL_ATTRIBUTES = %i[
|
|
34
|
+
pricing_basis
|
|
35
|
+
price_key
|
|
36
|
+
price_source
|
|
37
|
+
price_source_version
|
|
38
|
+
provider_field
|
|
39
|
+
provider_item_id
|
|
40
|
+
].freeze
|
|
41
|
+
SYMBOL_ATTRIBUTES = %i[
|
|
42
|
+
kind
|
|
43
|
+
direction
|
|
44
|
+
modality
|
|
45
|
+
cache_state
|
|
46
|
+
unit
|
|
47
|
+
pricing_basis
|
|
48
|
+
price_source
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
def self.build(attributes)
|
|
52
|
+
attributes = attributes.to_h
|
|
53
|
+
component = component_for(attributes)
|
|
54
|
+
normalized = {
|
|
55
|
+
kind: symbol_or_nil(attributes[:kind]) || component&.kind,
|
|
56
|
+
direction: symbol_or_nil(attributes[:direction]) || component&.direction,
|
|
57
|
+
modality: symbol_or_nil(attributes[:modality]) || component&.modality,
|
|
58
|
+
cache_state: symbol_or_nil(attributes[:cache_state]) || component&.cache_state,
|
|
59
|
+
quantity: decimal_or_zero(attributes[:quantity]),
|
|
60
|
+
unit: symbol_or_nil(attributes[:unit]) || component&.unit,
|
|
61
|
+
rate_amount: decimal_or_nil(attributes[:rate_amount]),
|
|
62
|
+
rate_quantity: decimal_or_nil(attributes[:rate_quantity]) || BigDecimal("1"),
|
|
63
|
+
cost: decimal_or_nil(attributes[:cost]),
|
|
64
|
+
currency: attributes[:currency] || USD,
|
|
65
|
+
cost_status: cost_status_for(attributes),
|
|
66
|
+
details: attributes[:details] || {}
|
|
67
|
+
}.merge(optional_attributes_for(attributes))
|
|
68
|
+
|
|
69
|
+
new(**normalized)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.from_token_usage(token_usage)
|
|
73
|
+
return [] unless token_usage
|
|
74
|
+
|
|
75
|
+
Components::TOKEN_PRICED.filter_map do |component|
|
|
76
|
+
quantity = token_usage.public_send(component.token_key)
|
|
77
|
+
next unless quantity.positive?
|
|
78
|
+
|
|
79
|
+
new(
|
|
80
|
+
kind: component.kind,
|
|
81
|
+
direction: component.direction,
|
|
82
|
+
modality: component.modality,
|
|
83
|
+
cache_state: component.cache_state,
|
|
84
|
+
quantity: BigDecimal(quantity.to_s),
|
|
85
|
+
unit: component.unit,
|
|
86
|
+
rate_amount: nil,
|
|
87
|
+
rate_quantity: BigDecimal("1"),
|
|
88
|
+
cost: nil,
|
|
89
|
+
currency: USD,
|
|
90
|
+
cost_status: CostStatus::UNKNOWN,
|
|
91
|
+
pricing_basis: nil,
|
|
92
|
+
price_key: nil,
|
|
93
|
+
price_source: nil,
|
|
94
|
+
price_source_version: nil,
|
|
95
|
+
provider_field: nil,
|
|
96
|
+
provider_item_id: nil,
|
|
97
|
+
details: {}
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.cost_status_for(attributes)
|
|
103
|
+
explicit = attributes[:cost_status]
|
|
104
|
+
return explicit.to_s if explicit
|
|
105
|
+
|
|
106
|
+
cost = decimal_or_nil(attributes[:cost])
|
|
107
|
+
return CostStatus::UNKNOWN if cost.nil?
|
|
108
|
+
|
|
109
|
+
cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.component_for(attributes)
|
|
113
|
+
component_key = attributes[:component_key] || attributes[:price_key]
|
|
114
|
+
return nil unless component_key
|
|
115
|
+
|
|
116
|
+
Components::BY_KEY[component_key.to_sym]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.symbol_or_nil(value)
|
|
120
|
+
return nil if value.nil?
|
|
121
|
+
|
|
122
|
+
value.is_a?(Symbol) ? value : value.to_s.to_sym
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.decimal_or_nil(value)
|
|
126
|
+
return nil if value.nil? || value == ""
|
|
127
|
+
|
|
128
|
+
BigDecimal(value.to_s)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.decimal_or_zero(value)
|
|
132
|
+
decimal_or_nil(value) || BigDecimal("0")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.optional_attributes_for(attributes)
|
|
136
|
+
OPTIONAL_ATTRIBUTES.to_h do |key|
|
|
137
|
+
value = attributes[key]
|
|
138
|
+
value = value.to_sym if value.is_a?(String) && SYMBOL_ATTRIBUTES.include?(key)
|
|
139
|
+
[key, value]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero,
|
|
144
|
+
:optional_attributes_for
|
|
145
|
+
|
|
146
|
+
def billable?
|
|
147
|
+
quantity.positive?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def priced?
|
|
151
|
+
[CostStatus::COMPLETE, CostStatus::FREE].include?(cost_status)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def unpriced?
|
|
155
|
+
cost_status == CostStatus::UNKNOWN
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def token?
|
|
159
|
+
unit == :token
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def cost_value
|
|
163
|
+
cost || BigDecimal("0")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def apply_rate(rate)
|
|
167
|
+
rate_amount = rate.fetch(:amount)
|
|
168
|
+
rate_quantity = rate.fetch(:quantity)
|
|
169
|
+
applied_cost = (quantity / rate_quantity) * rate_amount
|
|
170
|
+
with(
|
|
171
|
+
rate_amount: rate_amount,
|
|
172
|
+
rate_quantity: rate_quantity,
|
|
173
|
+
cost: applied_cost,
|
|
174
|
+
currency: rate.fetch(:currency),
|
|
175
|
+
cost_status: applied_cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE,
|
|
176
|
+
price_key: rate.fetch(:source_key),
|
|
177
|
+
price_source: rate.fetch(:source),
|
|
178
|
+
price_source_version: rate.fetch(:source_version)
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def to_h
|
|
183
|
+
super.transform_values do |value|
|
|
184
|
+
value.is_a?(BigDecimal) ? value.to_s("F") : value
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -5,20 +5,22 @@ require_relative "ledger"
|
|
|
5
5
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
class Budget
|
|
8
|
+
BUDGET_TYPE_TO_PERIOD = { monthly: :month, daily: :day }.freeze
|
|
9
|
+
|
|
8
10
|
class << self
|
|
9
11
|
def enforce!
|
|
10
12
|
config = LlmCostTracker.configuration
|
|
11
13
|
return unless config.budget_exceeded_behavior == :block_requests
|
|
12
14
|
|
|
13
|
-
budgets =
|
|
15
|
+
budgets = { monthly: config.monthly_budget, daily: config.daily_budget }.compact
|
|
14
16
|
return if budgets.empty?
|
|
15
17
|
|
|
16
|
-
totals =
|
|
18
|
+
totals = totals_for(budgets.keys, time: Time.now.utc)
|
|
17
19
|
|
|
18
|
-
budgets.each do |
|
|
19
|
-
total = totals.fetch(
|
|
20
|
+
budgets.each do |budget_type, budget|
|
|
21
|
+
total = totals.fetch(budget_type)
|
|
20
22
|
|
|
21
|
-
handle_exceeded(budget_type:
|
|
23
|
+
handle_exceeded(budget_type: budget_type, total: total, budget: budget) if total >= budget
|
|
22
24
|
end
|
|
23
25
|
end
|
|
24
26
|
|
|
@@ -27,13 +29,13 @@ module LlmCostTracker
|
|
|
27
29
|
return unless event.total_cost
|
|
28
30
|
|
|
29
31
|
check_per_call_budget(event, config)
|
|
30
|
-
budgets =
|
|
31
|
-
totals =
|
|
32
|
+
budgets = { daily: config.daily_budget, monthly: config.monthly_budget }.compact
|
|
33
|
+
totals = totals_for(budgets.keys, time: event.tracked_at)
|
|
32
34
|
|
|
33
|
-
budgets.each do |
|
|
34
|
-
total = totals.fetch(
|
|
35
|
+
budgets.each do |budget_type, budget|
|
|
36
|
+
total = totals.fetch(budget_type)
|
|
35
37
|
|
|
36
|
-
handle_exceeded(budget_type:
|
|
38
|
+
handle_exceeded(budget_type: budget_type, total: total, budget: budget, last_event: event) if total >= budget
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -43,30 +45,20 @@ module LlmCostTracker
|
|
|
43
45
|
budget = config.per_call_budget
|
|
44
46
|
return unless budget
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
return unless
|
|
48
|
+
total = event.total_cost
|
|
49
|
+
return unless total >= budget
|
|
48
50
|
|
|
49
|
-
handle_exceeded(budget_type: :per_call, total:
|
|
51
|
+
handle_exceeded(budget_type: :per_call, total: total, budget: budget, last_event: event)
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
def
|
|
53
|
-
{
|
|
54
|
-
monthly: config.monthly_budget,
|
|
55
|
-
daily: config.daily_budget
|
|
56
|
-
}.compact
|
|
57
|
-
end
|
|
54
|
+
def totals_for(budget_types, time:)
|
|
55
|
+
return {} if budget_types.empty?
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def totals_for_check(event, budgets)
|
|
67
|
-
return {} if budgets.empty?
|
|
68
|
-
|
|
69
|
-
LlmCostTracker::Ledger::Period::Totals.call(budgets.keys, time: event.tracked_at)
|
|
57
|
+
periods = budget_types.map { |type| BUDGET_TYPE_TO_PERIOD.fetch(type) }
|
|
58
|
+
period_totals = LlmCostTracker::Ledger::Period::Totals.call(periods, time: time)
|
|
59
|
+
BUDGET_TYPE_TO_PERIOD.each_with_object({}) do |(budget_type, period), totals|
|
|
60
|
+
totals[budget_type] = period_totals[period] if period_totals.key?(period)
|
|
61
|
+
end
|
|
70
62
|
end
|
|
71
63
|
|
|
72
64
|
def handle_exceeded(budget_type:, total:, budget:, last_event: nil)
|
|
@@ -85,16 +77,12 @@ module LlmCostTracker
|
|
|
85
77
|
end
|
|
86
78
|
|
|
87
79
|
def budget_payload(budget_type:, total:, budget:, last_event:)
|
|
88
|
-
|
|
80
|
+
{
|
|
89
81
|
budget_type: budget_type,
|
|
90
82
|
total: total,
|
|
91
83
|
budget: budget,
|
|
92
84
|
last_event: last_event
|
|
93
85
|
}
|
|
94
|
-
payload[:monthly_total] = total if budget_type == :monthly
|
|
95
|
-
payload[:daily_total] = total if budget_type == :daily
|
|
96
|
-
payload[:call_cost] = total if budget_type == :per_call
|
|
97
|
-
payload
|
|
98
86
|
end
|
|
99
87
|
|
|
100
88
|
def notify_exceeded?(config, budget_type:, total:, budget:, last_event:)
|
|
@@ -2,20 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
4
|
require "active_support/core_ext/object/deep_dup"
|
|
5
|
+
require "json"
|
|
5
6
|
|
|
6
7
|
require_relative "stream"
|
|
8
|
+
require_relative "../timing"
|
|
7
9
|
|
|
8
10
|
module LlmCostTracker
|
|
9
11
|
module Capture
|
|
10
12
|
class StreamCollector
|
|
11
13
|
attr_reader :provider
|
|
12
14
|
|
|
13
|
-
def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil,
|
|
14
|
-
|
|
15
|
+
def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, provider_project_id: nil,
|
|
16
|
+
provider_api_key_id: nil, provider_workspace_id: nil, batch: nil, pricing_mode: nil,
|
|
17
|
+
metadata: {}, context_tags: nil)
|
|
15
18
|
@provider = provider.to_s
|
|
16
19
|
@model = model
|
|
17
20
|
@latency_ms = latency_ms
|
|
18
21
|
@provider_response_id = provider_response_id
|
|
22
|
+
@provider_project_id = provider_project_id
|
|
23
|
+
@provider_api_key_id = provider_api_key_id
|
|
24
|
+
@provider_workspace_id = provider_workspace_id
|
|
25
|
+
@batch = batch
|
|
19
26
|
@pricing_mode = pricing_mode
|
|
20
27
|
@metadata = (metadata || {}).deep_dup
|
|
21
28
|
@context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
|
|
@@ -23,7 +30,7 @@ module LlmCostTracker
|
|
|
23
30
|
@captured_bytes = 0
|
|
24
31
|
@overflowed = false
|
|
25
32
|
@explicit_usage = nil
|
|
26
|
-
@started_at =
|
|
33
|
+
@started_at = LlmCostTracker::Timing.now_monotonic
|
|
27
34
|
@finished = false
|
|
28
35
|
@mutex = Mutex.new
|
|
29
36
|
end
|
|
@@ -66,10 +73,16 @@ module LlmCostTracker
|
|
|
66
73
|
@mutex.synchronize do
|
|
67
74
|
ensure_open!
|
|
68
75
|
@provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
|
|
69
|
-
@
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
@provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
|
|
77
|
+
@provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
|
|
78
|
+
@provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
|
|
79
|
+
batch = extra.delete(:batch)
|
|
80
|
+
@batch = batch unless batch.nil?
|
|
81
|
+
@explicit_usage = TokenUsage.build(
|
|
82
|
+
**extra.slice(*TokenUsage.members),
|
|
83
|
+
input_tokens: input_tokens,
|
|
84
|
+
output_tokens: output_tokens
|
|
85
|
+
)
|
|
73
86
|
end
|
|
74
87
|
self
|
|
75
88
|
end
|
|
@@ -79,6 +92,7 @@ module LlmCostTracker
|
|
|
79
92
|
return if @finished
|
|
80
93
|
|
|
81
94
|
@finished = true
|
|
95
|
+
pricing_mode = Pricing.normalize_mode(@pricing_mode)
|
|
82
96
|
{
|
|
83
97
|
events: @events.dup,
|
|
84
98
|
overflowed: @overflowed,
|
|
@@ -86,7 +100,8 @@ module LlmCostTracker
|
|
|
86
100
|
model: @model,
|
|
87
101
|
latency_ms: @latency_ms,
|
|
88
102
|
provider_response_id: @provider_response_id,
|
|
89
|
-
|
|
103
|
+
capture_dimensions: capture_dimensions(pricing_mode),
|
|
104
|
+
pricing_mode: pricing_mode,
|
|
90
105
|
metadata: @metadata.deep_dup,
|
|
91
106
|
context_tags: @context_tags.deep_dup
|
|
92
107
|
}
|
|
@@ -98,8 +113,7 @@ module LlmCostTracker
|
|
|
98
113
|
|
|
99
114
|
Tracker.record(
|
|
100
115
|
capture: capture,
|
|
101
|
-
latency_ms: snapshot[:latency_ms] ||
|
|
102
|
-
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
|
|
116
|
+
latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
|
|
103
117
|
pricing_mode: snapshot[:pricing_mode],
|
|
104
118
|
metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
|
|
105
119
|
context_tags: snapshot[:context_tags]
|
|
@@ -108,6 +122,16 @@ module LlmCostTracker
|
|
|
108
122
|
|
|
109
123
|
private
|
|
110
124
|
|
|
125
|
+
def capture_dimensions(pricing_mode)
|
|
126
|
+
batch = @batch.nil? ? UsageCapture.batch_from_pricing_mode?(pricing_mode).presence : @batch
|
|
127
|
+
{
|
|
128
|
+
provider_project_id: @provider_project_id.to_s.strip.presence,
|
|
129
|
+
provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
|
|
130
|
+
provider_workspace_id: @provider_workspace_id.to_s.strip.presence,
|
|
131
|
+
batch: batch
|
|
132
|
+
}.compact
|
|
133
|
+
end
|
|
134
|
+
|
|
111
135
|
def ensure_open!
|
|
112
136
|
return unless @finished
|
|
113
137
|
|
|
@@ -116,15 +140,14 @@ module LlmCostTracker
|
|
|
116
140
|
|
|
117
141
|
def build_usage_capture(snapshot)
|
|
118
142
|
return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
|
|
119
|
-
return build_unknown_usage(snapshot) if snapshot[:overflowed]
|
|
120
143
|
|
|
121
144
|
capture = Parsers.find_for_provider(@provider)&.parse_stream(
|
|
122
145
|
response_status: 200,
|
|
123
146
|
events: snapshot[:events]
|
|
124
147
|
)
|
|
125
|
-
if capture
|
|
148
|
+
if capture && (capture.usage_source != :unknown || !snapshot[:overflowed])
|
|
126
149
|
model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
|
|
127
|
-
return capture.with(provider: @provider, model: model)
|
|
150
|
+
return capture.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
|
|
128
151
|
end
|
|
129
152
|
|
|
130
153
|
build_unknown_usage(snapshot)
|
|
@@ -145,7 +168,9 @@ module LlmCostTracker
|
|
|
145
168
|
model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
|
|
146
169
|
token_usage: snapshot[:explicit_usage],
|
|
147
170
|
stream: true,
|
|
148
|
-
usage_source: :manual
|
|
171
|
+
usage_source: :manual,
|
|
172
|
+
pricing_mode: snapshot[:pricing_mode],
|
|
173
|
+
**snapshot.fetch(:capture_dimensions)
|
|
149
174
|
)
|
|
150
175
|
end
|
|
151
176
|
|
|
@@ -155,34 +180,23 @@ module LlmCostTracker
|
|
|
155
180
|
model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
|
|
156
181
|
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
157
182
|
stream: true,
|
|
158
|
-
usage_source: :unknown
|
|
183
|
+
usage_source: :unknown,
|
|
184
|
+
pricing_mode: snapshot[:pricing_mode],
|
|
185
|
+
**snapshot.fetch(:capture_dimensions)
|
|
159
186
|
)
|
|
160
187
|
end
|
|
161
188
|
|
|
162
189
|
def capture_event(data, type:)
|
|
163
|
-
|
|
190
|
+
event = { event: type, data: data }
|
|
191
|
+
size = JSON.generate(event).bytesize
|
|
164
192
|
if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
|
|
165
|
-
@events <<
|
|
193
|
+
@events << event.deep_dup
|
|
166
194
|
@captured_bytes += size
|
|
167
195
|
else
|
|
168
196
|
@overflowed = true
|
|
169
|
-
@events.clear
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def estimated_bytes(value)
|
|
174
|
-
case value
|
|
175
|
-
when Hash
|
|
176
|
-
value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
|
|
177
|
-
when Array
|
|
178
|
-
value.sum { |nested| estimated_bytes(nested) + 2 }
|
|
179
|
-
when String
|
|
180
|
-
value.bytesize + 2
|
|
181
|
-
when Numeric, true, false, nil
|
|
182
|
-
value.to_s.bytesize
|
|
183
|
-
else
|
|
184
|
-
value.to_s.bytesize + 2
|
|
185
197
|
end
|
|
198
|
+
rescue JSON::JSONError, TypeError
|
|
199
|
+
@overflowed = true
|
|
186
200
|
end
|
|
187
201
|
end
|
|
188
202
|
end
|