llm_cost_tracker 0.5.2 → 0.5.3
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 +16 -0
- data/README.md +8 -3
- data/docs/architecture.md +28 -0
- data/docs/budgets.md +45 -0
- data/docs/configuration.md +65 -0
- data/docs/cookbook.md +185 -0
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +38 -0
- data/docs/extending.md +32 -0
- data/docs/operations.md +44 -0
- data/docs/pricing.md +94 -0
- data/docs/querying.md +36 -0
- data/docs/streaming.md +70 -0
- data/docs/technical/README.md +10 -0
- data/docs/technical/data-flow.md +67 -0
- data/docs/technical/extension-points.md +111 -0
- data/docs/technical/module-map.md +197 -0
- data/docs/technical/operational-notes.md +77 -0
- data/docs/upgrading.md +46 -0
- data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
- data/lib/llm_cost_tracker/configuration.rb +2 -1
- data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
- data/lib/llm_cost_tracker/doctor.rb +6 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
- data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
- data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
- data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
- data/lib/llm_cost_tracker/pricing/lookup.rb +110 -0
- data/lib/llm_cost_tracker/pricing.rb +25 -108
- data/lib/llm_cost_tracker/retention.rb +3 -9
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +115 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +26 -0
- data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
- data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
- data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
- data/lib/llm_cost_tracker/storage/registry.rb +63 -0
- data/lib/llm_cost_tracker/tag_sql.rb +34 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +3 -0
- data/lib/tasks/llm_cost_tracker.rake +49 -0
- metadata +32 -2
|
@@ -4,10 +4,12 @@ require_relative "errors"
|
|
|
4
4
|
require_relative "tag_key"
|
|
5
5
|
require_relative "value_helpers"
|
|
6
6
|
require_relative "configuration/instrumentation"
|
|
7
|
+
require_relative "configuration/storage_backend"
|
|
7
8
|
|
|
8
9
|
module LlmCostTracker
|
|
9
10
|
class Configuration
|
|
10
11
|
include ConfigurationInstrumentation
|
|
12
|
+
include ConfigurationStorageBackend
|
|
11
13
|
|
|
12
14
|
OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
|
|
13
15
|
|
|
@@ -18,7 +20,6 @@ module LlmCostTracker
|
|
|
18
20
|
SHARED_SCALAR_ATTRIBUTES = %i[enabled custom_storage on_budget_exceeded monthly_budget daily_budget per_call_budget
|
|
19
21
|
log_level prices_file max_tag_count max_tag_value_bytesize].freeze
|
|
20
22
|
SHARED_ENUM_ATTRIBUTES = {
|
|
21
|
-
storage_backend: [STORAGE_BACKENDS, :log],
|
|
22
23
|
budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
|
|
23
24
|
storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
|
|
24
25
|
unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Doctor
|
|
5
|
+
class CaptureCheck
|
|
6
|
+
def self.call(check_class)
|
|
7
|
+
new(check_class).call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(check_class)
|
|
11
|
+
@check_class = check_class
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
config = LlmCostTracker.configuration
|
|
16
|
+
return disabled_check unless config.enabled
|
|
17
|
+
return integrations_check(config.instrumented_integrations) if config.instrumented_integrations.any?
|
|
18
|
+
|
|
19
|
+
check(:ok, "no SDK integrations enabled; Faraday middleware and manual capture remain available")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :check_class
|
|
25
|
+
|
|
26
|
+
def disabled_check
|
|
27
|
+
check(:warn, "tracking is disabled; set config.enabled = true to record calls")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def integrations_check(integrations)
|
|
31
|
+
check(:ok, "SDK integrations enabled: #{integrations.join(', ')}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check(status, message)
|
|
35
|
+
check_class.new(status, "capture", message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "price_freshness"
|
|
4
|
+
require_relative "doctor/capture_check"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
class Doctor
|
|
@@ -38,6 +39,7 @@ module LlmCostTracker
|
|
|
38
39
|
def checks
|
|
39
40
|
[
|
|
40
41
|
configuration_check,
|
|
42
|
+
capture_check,
|
|
41
43
|
*integration_checks,
|
|
42
44
|
active_record_check,
|
|
43
45
|
table_check,
|
|
@@ -51,9 +53,12 @@ module LlmCostTracker
|
|
|
51
53
|
private
|
|
52
54
|
|
|
53
55
|
def configuration_check
|
|
54
|
-
|
|
56
|
+
config = LlmCostTracker.configuration
|
|
57
|
+
Check.new(:ok, "configuration", "storage_backend=#{config.storage_backend.inspect}, enabled=#{config.enabled}")
|
|
55
58
|
end
|
|
56
59
|
|
|
60
|
+
def capture_check = CaptureCheck.call(Check)
|
|
61
|
+
|
|
57
62
|
def integration_checks
|
|
58
63
|
LlmCostTracker::Integrations.checks.map do |check|
|
|
59
64
|
Check.new(check.status, check.name.to_s, check.message)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "stream_tracker"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Integrations
|
|
@@ -16,11 +17,11 @@ module LlmCostTracker
|
|
|
16
17
|
|
|
17
18
|
def patch_targets
|
|
18
19
|
[
|
|
19
|
-
patch_target("Anthropic::Resources::Messages", with: MessagesPatch, methods:
|
|
20
|
+
patch_target("Anthropic::Resources::Messages", with: MessagesPatch, methods: %i[create stream stream_raw]),
|
|
20
21
|
patch_target(
|
|
21
22
|
"Anthropic::Resources::Beta::Messages",
|
|
22
23
|
with: MessagesPatch,
|
|
23
|
-
methods:
|
|
24
|
+
methods: %i[create stream stream_raw],
|
|
24
25
|
optional: true
|
|
25
26
|
)
|
|
26
27
|
]
|
|
@@ -64,6 +65,28 @@ module LlmCostTracker
|
|
|
64
65
|
ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens)
|
|
65
66
|
)
|
|
66
67
|
end
|
|
68
|
+
|
|
69
|
+
def track_stream(stream, collector:)
|
|
70
|
+
return stream unless active?
|
|
71
|
+
|
|
72
|
+
StreamTracker.wrap(
|
|
73
|
+
stream,
|
|
74
|
+
collector: collector,
|
|
75
|
+
active: -> { active? },
|
|
76
|
+
finish: ->(errored:) { finish_stream(collector, errored: errored) }
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stream_collector(request)
|
|
81
|
+
LlmCostTracker::StreamCollector.new(
|
|
82
|
+
provider: "anthropic",
|
|
83
|
+
model: request[:model] || request["model"]
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def finish_stream(collector, errored:)
|
|
88
|
+
record_safely { collector.finish!(errored: errored) }
|
|
89
|
+
end
|
|
67
90
|
end
|
|
68
91
|
|
|
69
92
|
module MessagesPatch
|
|
@@ -78,6 +101,22 @@ module LlmCostTracker
|
|
|
78
101
|
)
|
|
79
102
|
message
|
|
80
103
|
end
|
|
104
|
+
|
|
105
|
+
def stream(*args, **kwargs)
|
|
106
|
+
request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
|
|
107
|
+
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
108
|
+
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
109
|
+
stream = super
|
|
110
|
+
LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def stream_raw(*args, **kwargs)
|
|
114
|
+
request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
|
|
115
|
+
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
116
|
+
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
117
|
+
stream = super
|
|
118
|
+
LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
|
|
119
|
+
end
|
|
81
120
|
end
|
|
82
121
|
end
|
|
83
122
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "stream_tracker"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Integrations
|
|
@@ -16,8 +17,16 @@ module LlmCostTracker
|
|
|
16
17
|
|
|
17
18
|
def patch_targets
|
|
18
19
|
[
|
|
19
|
-
patch_target(
|
|
20
|
-
|
|
20
|
+
patch_target(
|
|
21
|
+
"OpenAI::Resources::Responses",
|
|
22
|
+
with: ResponsesPatch,
|
|
23
|
+
methods: %i[create stream stream_raw retrieve_streaming]
|
|
24
|
+
),
|
|
25
|
+
patch_target(
|
|
26
|
+
"OpenAI::Resources::Chat::Completions",
|
|
27
|
+
with: ChatCompletionsPatch,
|
|
28
|
+
methods: %i[create stream_raw]
|
|
29
|
+
)
|
|
21
30
|
]
|
|
22
31
|
end
|
|
23
32
|
|
|
@@ -70,6 +79,28 @@ module LlmCostTracker
|
|
|
70
79
|
def regular_input_tokens(input_tokens, cache_read)
|
|
71
80
|
[ObjectReader.integer(input_tokens) - cache_read.to_i, 0].max
|
|
72
81
|
end
|
|
82
|
+
|
|
83
|
+
def track_stream(stream, collector:)
|
|
84
|
+
return stream unless active?
|
|
85
|
+
|
|
86
|
+
StreamTracker.wrap(
|
|
87
|
+
stream,
|
|
88
|
+
collector: collector,
|
|
89
|
+
active: -> { active? },
|
|
90
|
+
finish: ->(errored:) { finish_stream(collector, errored: errored) }
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stream_collector(request)
|
|
95
|
+
LlmCostTracker::StreamCollector.new(
|
|
96
|
+
provider: "openai",
|
|
97
|
+
model: request[:model] || request["model"]
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def finish_stream(collector, errored:)
|
|
102
|
+
record_safely { collector.finish!(errored: errored) }
|
|
103
|
+
end
|
|
73
104
|
end
|
|
74
105
|
|
|
75
106
|
module ResponsesPatch
|
|
@@ -84,6 +115,31 @@ module LlmCostTracker
|
|
|
84
115
|
)
|
|
85
116
|
response
|
|
86
117
|
end
|
|
118
|
+
|
|
119
|
+
def stream(*args, **kwargs)
|
|
120
|
+
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
121
|
+
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
122
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
123
|
+
stream = super
|
|
124
|
+
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def stream_raw(*args, **kwargs)
|
|
128
|
+
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
129
|
+
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
130
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
131
|
+
stream = super
|
|
132
|
+
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def retrieve_streaming(response_id, *args, **kwargs)
|
|
136
|
+
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
137
|
+
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
138
|
+
collector.provider_response_id = response_id
|
|
139
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
140
|
+
stream = super
|
|
141
|
+
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
142
|
+
end
|
|
87
143
|
end
|
|
88
144
|
|
|
89
145
|
module ChatCompletionsPatch
|
|
@@ -98,6 +154,14 @@ module LlmCostTracker
|
|
|
98
154
|
)
|
|
99
155
|
response
|
|
100
156
|
end
|
|
157
|
+
|
|
158
|
+
def stream_raw(*args, **kwargs)
|
|
159
|
+
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
160
|
+
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
161
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
162
|
+
stream = super
|
|
163
|
+
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
164
|
+
end
|
|
101
165
|
end
|
|
102
166
|
end
|
|
103
167
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
3
6
|
require_relative "openai"
|
|
4
7
|
require_relative "anthropic"
|
|
5
8
|
require_relative "ruby_llm"
|
|
@@ -7,14 +10,22 @@ require_relative "ruby_llm"
|
|
|
7
10
|
module LlmCostTracker
|
|
8
11
|
module Integrations
|
|
9
12
|
module Registry
|
|
10
|
-
|
|
13
|
+
DEFAULT_INTEGRATIONS = {
|
|
11
14
|
openai: Openai,
|
|
12
15
|
anthropic: Anthropic,
|
|
13
16
|
ruby_llm: RubyLlm
|
|
14
17
|
}.freeze
|
|
18
|
+
MUTEX = Monitor.new
|
|
15
19
|
|
|
16
20
|
module_function
|
|
17
21
|
|
|
22
|
+
def register(name, integration)
|
|
23
|
+
key = name.to_sym
|
|
24
|
+
validate_integration!(integration)
|
|
25
|
+
MUTEX.synchronize { @integrations = integrations.merge(key => integration).freeze }
|
|
26
|
+
integration
|
|
27
|
+
end
|
|
28
|
+
|
|
18
29
|
def install!(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
19
30
|
normalize(names).each { |name| fetch(name).install }
|
|
20
31
|
end
|
|
@@ -30,13 +41,32 @@ module LlmCostTracker
|
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
def fetch(name)
|
|
33
|
-
|
|
34
|
-
message = "Unknown integration: #{name.inspect}. Use one of: #{
|
|
44
|
+
integrations.fetch(name.to_sym) do
|
|
45
|
+
message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
|
|
35
46
|
raise LlmCostTracker::Error, message
|
|
36
47
|
end
|
|
37
48
|
end
|
|
49
|
+
|
|
50
|
+
def names
|
|
51
|
+
integrations.keys
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reset!
|
|
55
|
+
MUTEX.synchronize { @integrations = DEFAULT_INTEGRATIONS.dup.freeze }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def integrations
|
|
59
|
+
@integrations || MUTEX.synchronize { @integrations ||= DEFAULT_INTEGRATIONS.dup.freeze }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_integration!(integration)
|
|
63
|
+
return if integration.respond_to?(:install) && integration.respond_to?(:status)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "integration must respond to install and status"
|
|
66
|
+
end
|
|
38
67
|
end
|
|
39
68
|
|
|
69
|
+
def self.register(name, integration) = Registry.register(name, integration)
|
|
40
70
|
def self.install! = Registry.install!
|
|
41
71
|
def self.checks = Registry.checks
|
|
42
72
|
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
require_relative "../logging"
|
|
6
|
+
require_relative "../stream_collector"
|
|
7
|
+
require_relative "../value_helpers"
|
|
8
|
+
require_relative "object_reader"
|
|
9
|
+
|
|
10
|
+
module LlmCostTracker
|
|
11
|
+
module Integrations
|
|
12
|
+
class StreamTracker
|
|
13
|
+
def self.wrap(stream, collector:, active:, finish: nil) = new(stream, collector, active, finish).wrap
|
|
14
|
+
|
|
15
|
+
def initialize(stream, collector, active, finish)
|
|
16
|
+
@stream = stream
|
|
17
|
+
@collector = collector
|
|
18
|
+
@active = active
|
|
19
|
+
@finish = finish || proc { |errored:| @collector.finish!(errored: errored) }
|
|
20
|
+
@finished = false
|
|
21
|
+
@capture_failed = false
|
|
22
|
+
@monitor = Monitor.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def wrap
|
|
26
|
+
return @stream unless @stream
|
|
27
|
+
|
|
28
|
+
iterator_wrapped = @stream.instance_variable_defined?(:@iterator) && wrap_iterator?
|
|
29
|
+
wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
|
|
30
|
+
|
|
31
|
+
@stream
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
|
|
34
|
+
@stream
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def wrap_iterator?
|
|
40
|
+
iterator = @stream.instance_variable_get(:@iterator)
|
|
41
|
+
return false unless iterator.respond_to?(:each)
|
|
42
|
+
|
|
43
|
+
@stream.instance_variable_set(:@iterator, tracked_iterator(iterator))
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def wrap_each
|
|
48
|
+
tracker = self
|
|
49
|
+
original_each = @stream.method(:each)
|
|
50
|
+
@stream.define_singleton_method(:each) do |&block|
|
|
51
|
+
next enum_for(:each) unless block
|
|
52
|
+
|
|
53
|
+
tracker.__send__(:each_from, original_each, &block)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tracked_iterator(iterator)
|
|
58
|
+
Enumerator.new do |yielder|
|
|
59
|
+
each_from(iterator) { |event| yielder << event }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each_from(iterable)
|
|
64
|
+
errored = false
|
|
65
|
+
iterate(iterable) do |event|
|
|
66
|
+
capture(event)
|
|
67
|
+
yield event
|
|
68
|
+
end
|
|
69
|
+
rescue StandardError
|
|
70
|
+
errored = true
|
|
71
|
+
raise
|
|
72
|
+
ensure
|
|
73
|
+
finish!(errored: errored)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def iterate(iterable, &)
|
|
77
|
+
if iterable.respond_to?(:each)
|
|
78
|
+
iterable.each(&)
|
|
79
|
+
else
|
|
80
|
+
iterable.call(&)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def capture(event)
|
|
85
|
+
payload = normalize(event_payload(event))
|
|
86
|
+
@collector.event(payload, type: event_type(event, payload))
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
warn_capture_failure(e)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def event_payload(event)
|
|
92
|
+
if event.respond_to?(:deep_to_h)
|
|
93
|
+
event.deep_to_h
|
|
94
|
+
elsif event.respond_to?(:to_h)
|
|
95
|
+
event.to_h
|
|
96
|
+
else
|
|
97
|
+
event_attributes(event)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def event_attributes(event)
|
|
102
|
+
%i[type id model usage response message].each_with_object({}) do |key, attributes|
|
|
103
|
+
value = ObjectReader.read(event, key)
|
|
104
|
+
attributes[key] = value unless value.nil?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def event_type(event, payload)
|
|
109
|
+
value = ObjectReader.first(event, :type) || payload["type"]
|
|
110
|
+
value&.to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def normalize(value)
|
|
114
|
+
case value
|
|
115
|
+
when Hash
|
|
116
|
+
value.each_with_object({}) do |(key, nested), normalized|
|
|
117
|
+
normalized[key.to_s] = normalize(nested)
|
|
118
|
+
end
|
|
119
|
+
when Array
|
|
120
|
+
value.map { |nested| normalize(nested) }
|
|
121
|
+
when Symbol
|
|
122
|
+
value.to_s
|
|
123
|
+
when NilClass
|
|
124
|
+
nil
|
|
125
|
+
else
|
|
126
|
+
converted = object_hash(value)
|
|
127
|
+
converted ? normalize(converted) : ValueHelpers.deep_dup(value)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def object_hash(value)
|
|
132
|
+
if value.respond_to?(:deep_to_h)
|
|
133
|
+
value.deep_to_h
|
|
134
|
+
elsif value.respond_to?(:to_h)
|
|
135
|
+
value.to_h
|
|
136
|
+
end
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def warn_capture_failure(error)
|
|
142
|
+
should_warn = @monitor.synchronize do
|
|
143
|
+
next false if @capture_failed
|
|
144
|
+
|
|
145
|
+
@capture_failed = true
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
return unless should_warn
|
|
149
|
+
|
|
150
|
+
Logging.warn("stream integration failed to capture event: #{error.class}: #{error.message}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def finish!(errored:)
|
|
154
|
+
should_finish = @monitor.synchronize do
|
|
155
|
+
next false if @finished
|
|
156
|
+
|
|
157
|
+
@finished = true
|
|
158
|
+
true
|
|
159
|
+
end
|
|
160
|
+
return unless should_finish && @active.call
|
|
161
|
+
|
|
162
|
+
@finish.call(errored: errored)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
|
|
5
|
+
require_relative "llm_api_call_metrics"
|
|
5
6
|
require_relative "period_grouping"
|
|
6
7
|
require_relative "tag_accessors"
|
|
7
|
-
require_relative "tag_key"
|
|
8
8
|
require_relative "tag_query"
|
|
9
9
|
require_relative "tags_column"
|
|
10
10
|
|
|
@@ -12,6 +12,7 @@ module LlmCostTracker
|
|
|
12
12
|
class LlmApiCall < ActiveRecord::Base
|
|
13
13
|
extend PeriodGrouping
|
|
14
14
|
extend TagsColumn
|
|
15
|
+
extend LlmApiCallMetrics
|
|
15
16
|
include TagAccessors
|
|
16
17
|
|
|
17
18
|
self.table_name = "llm_api_calls"
|
|
@@ -55,82 +56,5 @@ module LlmCostTracker
|
|
|
55
56
|
def self.by_tags(tags)
|
|
56
57
|
TagQuery.apply(self, tags)
|
|
57
58
|
end
|
|
58
|
-
|
|
59
|
-
def self.total_cost
|
|
60
|
-
sum(:total_cost).to_f
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def self.total_tokens
|
|
64
|
-
sum(:total_tokens).to_i
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def self.cost_by_model
|
|
68
|
-
group(:model).sum(:total_cost)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def self.cost_by_provider
|
|
72
|
-
group(:provider).sum(:total_cost)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def self.group_by_tag(key)
|
|
76
|
-
group(Arel.sql(tag_value_expression(key)))
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def self.cost_by_tag(key, limit: nil)
|
|
80
|
-
relation = group_by_tag(key).order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
81
|
-
relation = relation.limit(limit) if limit
|
|
82
|
-
|
|
83
|
-
costs = relation.sum(:total_cost).each_with_object(Hash.new(0.0)) do |(tag_value, cost), grouped|
|
|
84
|
-
grouped[tag_value_label(tag_value)] += cost.to_f
|
|
85
|
-
end
|
|
86
|
-
costs.sort_by { |_label, cost| -cost }.to_h
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def self.average_latency_ms
|
|
90
|
-
return nil unless latency_column?
|
|
91
|
-
|
|
92
|
-
average(:latency_ms)&.to_f
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def self.latency_by_model
|
|
96
|
-
return {} unless latency_column?
|
|
97
|
-
|
|
98
|
-
group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def self.latency_by_provider
|
|
102
|
-
return {} unless latency_column?
|
|
103
|
-
|
|
104
|
-
group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def self.tag_value_label(value)
|
|
108
|
-
value.nil? || value == "" ? "(untagged)" : value.to_s
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def self.tag_value_expression(key, table_name: quoted_table_name)
|
|
112
|
-
key = validated_tag_key(key)
|
|
113
|
-
column = "#{table_name}.#{connection.quote_column_name('tags')}"
|
|
114
|
-
|
|
115
|
-
case connection.adapter_name
|
|
116
|
-
when /postgres/i
|
|
117
|
-
json_column = tags_jsonb_column? ? column : "(#{column})::jsonb"
|
|
118
|
-
"#{json_column}->>#{connection.quote(key)}"
|
|
119
|
-
when /mysql/i
|
|
120
|
-
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"
|
|
121
|
-
else
|
|
122
|
-
"json_extract(#{column}, #{connection.quote(json_path(key))})"
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def self.validated_tag_key(key)
|
|
127
|
-
TagKey.validate!(key)
|
|
128
|
-
end
|
|
129
|
-
private_class_method :validated_tag_key
|
|
130
|
-
|
|
131
|
-
def self.json_path(key)
|
|
132
|
-
"$.\"#{key}\""
|
|
133
|
-
end
|
|
134
|
-
private_class_method :json_path
|
|
135
59
|
end
|
|
136
60
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tag_sql"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module LlmApiCallMetrics
|
|
7
|
+
def total_cost
|
|
8
|
+
sum(:total_cost).to_f
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def total_tokens
|
|
12
|
+
sum(:total_tokens).to_i
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cost_by_model
|
|
16
|
+
group(:model).sum(:total_cost)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def cost_by_provider
|
|
20
|
+
group(:provider).sum(:total_cost)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def group_by_tag(key)
|
|
24
|
+
group(Arel.sql(tag_value_expression(key)))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cost_by_tag(key, limit: nil)
|
|
28
|
+
relation = group_by_tag(key).order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
29
|
+
relation = relation.limit(limit) if limit
|
|
30
|
+
|
|
31
|
+
costs = relation.sum(:total_cost).each_with_object(Hash.new(0.0)) do |(tag_value, cost), grouped|
|
|
32
|
+
grouped[tag_value_label(tag_value)] += cost.to_f
|
|
33
|
+
end
|
|
34
|
+
costs.sort_by { |_label, cost| -cost }.to_h
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def average_latency_ms
|
|
38
|
+
return nil unless latency_column?
|
|
39
|
+
|
|
40
|
+
average(:latency_ms)&.to_f
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def latency_by_model
|
|
44
|
+
return {} unless latency_column?
|
|
45
|
+
|
|
46
|
+
group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def latency_by_provider
|
|
50
|
+
return {} unless latency_column?
|
|
51
|
+
|
|
52
|
+
group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tag_value_label(value)
|
|
56
|
+
TagSql.value_label(value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def tag_value_expression(key, table_name: quoted_table_name)
|
|
60
|
+
TagSql.value_expression(self, key, table_name: table_name)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|