llm_cost_tracker 0.5.2 → 0.6.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/CHANGELOG.md +46 -0
- data/README.md +8 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
- 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 +70 -0
- data/docs/technical/extension-points.md +111 -0
- data/docs/technical/module-map.md +197 -0
- data/docs/technical/operational-notes.md +97 -0
- data/docs/upgrading.md +47 -0
- data/lib/llm_cost_tracker/active_record_adapter.rb +49 -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/ingestion_check.rb +117 -0
- data/lib/llm_cost_tracker/doctor.rb +8 -1
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
- data/lib/llm_cost_tracker/inbox_event.rb +9 -0
- data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
- 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/period_grouping.rb +4 -3
- 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 +143 -0
- data/lib/llm_cost_tracker/pricing.rb +25 -108
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/retention.rb +3 -9
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
- data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +59 -55
- data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
- 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/stream_collector.rb +18 -7
- data/lib/llm_cost_tracker/tag_sql.rb +34 -0
- data/lib/llm_cost_tracker/tags_column.rb +7 -1
- data/lib/llm_cost_tracker/tracker.rb +3 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +39 -1
- data/lib/tasks/llm_cost_tracker.rake +49 -0
- metadata +47 -2
|
@@ -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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "active_record_adapter"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module PeriodGrouping
|
|
5
7
|
PERIOD_FORMATS = {
|
|
@@ -34,10 +36,9 @@ module LlmCostTracker
|
|
|
34
36
|
column = period_column_expression(column)
|
|
35
37
|
formats = PERIOD_FORMATS.fetch(period)
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
when /postgres/i
|
|
39
|
+
if ActiveRecordAdapter.postgresql?(connection)
|
|
39
40
|
postgres_period_expression(period, column, formats)
|
|
40
|
-
|
|
41
|
+
elsif ActiveRecordAdapter.mysql?(connection)
|
|
41
42
|
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
42
43
|
else
|
|
43
44
|
"strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
EffectivePriceSet = Data.define(:input, :cache_read_input, :cache_write_input, :output) do
|
|
6
|
+
def to_h
|
|
7
|
+
{
|
|
8
|
+
input: input,
|
|
9
|
+
cache_read_input: cache_read_input,
|
|
10
|
+
cache_write_input: cache_write_input,
|
|
11
|
+
output: output
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def complete?
|
|
16
|
+
missing_keys.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def missing_keys
|
|
20
|
+
to_h.filter_map { |key, value| key if value.nil? }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module EffectivePrices
|
|
25
|
+
class << self
|
|
26
|
+
def call(usage:, prices:, pricing_mode:)
|
|
27
|
+
EffectivePriceSet.new(
|
|
28
|
+
input: price_for_usage(usage.input_tokens, prices, :input, pricing_mode),
|
|
29
|
+
cache_read_input: price_for_cache_usage(
|
|
30
|
+
usage.cache_read_input_tokens,
|
|
31
|
+
prices,
|
|
32
|
+
:cache_read_input,
|
|
33
|
+
pricing_mode
|
|
34
|
+
),
|
|
35
|
+
cache_write_input: price_for_cache_usage(
|
|
36
|
+
usage.cache_write_input_tokens,
|
|
37
|
+
prices,
|
|
38
|
+
:cache_write_input,
|
|
39
|
+
pricing_mode
|
|
40
|
+
),
|
|
41
|
+
output: price_for_usage(usage.output_tokens, prices, :output, pricing_mode)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def price_for_cache_usage(tokens, prices, key, pricing_mode)
|
|
48
|
+
return 0.0 unless tokens.positive?
|
|
49
|
+
|
|
50
|
+
price_for(prices, key, pricing_mode) || price_for(prices, :input, pricing_mode)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def price_for_usage(tokens, prices, key, pricing_mode)
|
|
54
|
+
tokens.positive? ? price_for(prices, key, pricing_mode) : 0.0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def price_for(prices, key, pricing_mode)
|
|
58
|
+
mode = normalized_pricing_mode(pricing_mode)
|
|
59
|
+
return prices[key] unless mode
|
|
60
|
+
|
|
61
|
+
prices[:"#{mode}_#{key}"] || prices[key]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalized_pricing_mode(value)
|
|
65
|
+
return nil if value.nil?
|
|
66
|
+
|
|
67
|
+
mode = value.to_s.strip
|
|
68
|
+
return nil if mode.empty? || mode == "standard"
|
|
69
|
+
|
|
70
|
+
mode
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|