llm_cost_tracker 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +21 -16
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/lib/llm_cost_tracker/budget.rb +8 -20
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +33 -36
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +63 -71
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
- data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
- data/lib/llm_cost_tracker/parsers/base.rb +12 -21
- data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
- data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +361 -36
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
- data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -8
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -8
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +39 -69
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -78
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +54 -58
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -67
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/writer.rb +0 -35
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -105
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "../capture/stream_collector"
|
|
5
|
+
require_relative "../capture/stream_tracker"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
7
8
|
module Integrations
|
|
@@ -9,11 +10,17 @@ module LlmCostTracker
|
|
|
9
10
|
extend Base
|
|
10
11
|
|
|
11
12
|
class << self
|
|
12
|
-
def integration_name
|
|
13
|
+
def integration_name
|
|
14
|
+
:anthropic
|
|
15
|
+
end
|
|
13
16
|
|
|
14
|
-
def minimum_version
|
|
17
|
+
def minimum_version
|
|
18
|
+
"1.36.0"
|
|
19
|
+
end
|
|
15
20
|
|
|
16
|
-
def version_constant
|
|
21
|
+
def version_constant
|
|
22
|
+
"Anthropic::VERSION"
|
|
23
|
+
end
|
|
17
24
|
|
|
18
25
|
def patch_targets
|
|
19
26
|
[
|
|
@@ -31,56 +38,84 @@ module LlmCostTracker
|
|
|
31
38
|
return unless active?
|
|
32
39
|
|
|
33
40
|
record_safely do
|
|
34
|
-
usage =
|
|
41
|
+
usage = object_value(message, :usage)
|
|
35
42
|
next unless usage
|
|
36
43
|
|
|
37
|
-
input_tokens =
|
|
38
|
-
output_tokens =
|
|
44
|
+
input_tokens = object_value(usage, :input_tokens)
|
|
45
|
+
output_tokens = object_value(usage, :output_tokens)
|
|
39
46
|
next if input_tokens.nil? && output_tokens.nil?
|
|
40
47
|
|
|
41
48
|
LlmCostTracker::Tracker.record(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
capture: UsageCapture.build(
|
|
50
|
+
provider: "anthropic",
|
|
51
|
+
model: object_value(message, :model) || request[:model],
|
|
52
|
+
pricing_mode: pricing_mode(message: message, request: request, usage: usage),
|
|
53
|
+
token_usage: token_usage(usage: usage, input_tokens: input_tokens, output_tokens: output_tokens),
|
|
54
|
+
usage_source: :sdk_response,
|
|
55
|
+
provider_response_id: object_value(message, :id)
|
|
56
|
+
),
|
|
57
|
+
latency_ms: latency_ms
|
|
50
58
|
)
|
|
51
59
|
end
|
|
52
60
|
end
|
|
53
61
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
def token_usage(usage:, input_tokens:, output_tokens:)
|
|
63
|
+
cache_write_1h = object_dig(usage, :cache_creation, :ephemeral_1h_input_tokens).to_i
|
|
64
|
+
cache_write_5m = object_dig(usage, :cache_creation, :ephemeral_5m_input_tokens)
|
|
65
|
+
cache_write = if cache_write_5m.nil?
|
|
66
|
+
total_cache_write = object_value(usage, :cache_creation_input_tokens)
|
|
67
|
+
[total_cache_write.to_i - cache_write_1h, 0].max
|
|
68
|
+
else
|
|
69
|
+
cache_write_5m.to_i
|
|
70
|
+
end
|
|
71
|
+
hidden_output = (
|
|
72
|
+
object_value(usage, :thinking_tokens, :thinking_output_tokens) ||
|
|
73
|
+
object_dig(usage, :output_tokens_details, :reasoning_tokens)
|
|
74
|
+
).to_i
|
|
75
|
+
|
|
76
|
+
TokenUsage.build(
|
|
77
|
+
input_tokens: input_tokens.to_i,
|
|
78
|
+
output_tokens: output_tokens.to_i,
|
|
79
|
+
cache_read_input_tokens: object_value(usage, :cache_read_input_tokens).to_i,
|
|
80
|
+
cache_write_input_tokens: cache_write,
|
|
81
|
+
cache_write_1h_input_tokens: cache_write_1h,
|
|
82
|
+
hidden_output_tokens: hidden_output
|
|
83
|
+
)
|
|
60
84
|
end
|
|
61
85
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
def pricing_mode(message:, request:, usage:)
|
|
87
|
+
modes = [
|
|
88
|
+
Pricing.normalize_mode(object_value(usage, :speed) || object_value(message, :speed) || request[:speed]),
|
|
89
|
+
Pricing.normalize_mode(
|
|
90
|
+
object_value(usage, :service_tier) || object_value(message, :service_tier) || request[:service_tier]
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
modes << "data_residency" if inference_geo(message: message, request: request, usage: usage).to_s == "us"
|
|
94
|
+
modes = modes.compact.uniq
|
|
95
|
+
modes.empty? ? nil : modes.join("_")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def inference_geo(message:, request:, usage:)
|
|
99
|
+
object_value(usage, :inference_geo) ||
|
|
100
|
+
object_value(message, :inference_geo) ||
|
|
101
|
+
request[:inference_geo]
|
|
67
102
|
end
|
|
68
103
|
|
|
69
104
|
def track_stream(stream, collector:)
|
|
70
105
|
return stream unless active?
|
|
71
106
|
|
|
72
|
-
StreamTracker.
|
|
73
|
-
stream,
|
|
107
|
+
LlmCostTracker::Capture::StreamTracker.new(
|
|
108
|
+
stream: stream,
|
|
74
109
|
collector: collector,
|
|
75
110
|
active: -> { active? },
|
|
76
111
|
finish: ->(errored:) { finish_stream(collector, errored: errored) }
|
|
77
|
-
)
|
|
112
|
+
).wrap
|
|
78
113
|
end
|
|
79
114
|
|
|
80
115
|
def stream_collector(request)
|
|
81
|
-
LlmCostTracker::StreamCollector.new(
|
|
116
|
+
LlmCostTracker::Capture::StreamCollector.new(
|
|
82
117
|
provider: "anthropic",
|
|
83
|
-
model: request[:model]
|
|
118
|
+
model: request[:model]
|
|
84
119
|
)
|
|
85
120
|
end
|
|
86
121
|
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
4
|
+
require "active_support/core_ext/object/try"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
6
|
+
|
|
3
7
|
require_relative "../logging"
|
|
4
|
-
require_relative "object_reader"
|
|
5
8
|
|
|
6
9
|
module LlmCostTracker
|
|
7
10
|
module Integrations
|
|
8
11
|
module Base
|
|
9
|
-
PatchTarget = Data.define(:constant_name, :patch, :method_names, :optional)
|
|
10
12
|
Result = Data.define(:name, :status, :message)
|
|
11
13
|
|
|
12
14
|
def active?
|
|
@@ -16,20 +18,22 @@ module LlmCostTracker
|
|
|
16
18
|
def install
|
|
17
19
|
validate_contract!
|
|
18
20
|
patch_targets.each do |target|
|
|
19
|
-
target_class =
|
|
20
|
-
install_patch(target_class, target.patch) if target_class
|
|
21
|
+
target_class = target.fetch(:constant_name).to_s.safe_constantize
|
|
22
|
+
install_patch(target_class, target.fetch(:patch)) if target_class
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def status
|
|
25
27
|
name = integration_name
|
|
26
|
-
problems =
|
|
28
|
+
problems = version_problems + target_problems
|
|
27
29
|
if problems.any?
|
|
28
30
|
return Result.new(name, :warn, "#{name} integration cannot be installed: #{problems.join('; ')}")
|
|
29
31
|
end
|
|
30
32
|
|
|
31
|
-
required_targets = patch_targets.reject(
|
|
32
|
-
installed = required_targets.count
|
|
33
|
+
required_targets = patch_targets.reject { |target| target.fetch(:optional) }
|
|
34
|
+
installed = required_targets.count do |target|
|
|
35
|
+
target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
|
|
36
|
+
end
|
|
33
37
|
return Result.new(name, :ok, "#{name} integration installed") if installed == required_targets.count
|
|
34
38
|
|
|
35
39
|
Result.new(name, :warn, "#{name} integration is enabled but not installed")
|
|
@@ -53,14 +57,31 @@ module LlmCostTracker
|
|
|
53
57
|
|
|
54
58
|
def request_params(args, kwargs)
|
|
55
59
|
params = args.first.is_a?(Hash) ? args.first : {}
|
|
56
|
-
params.merge(kwargs)
|
|
60
|
+
params.merge(kwargs).with_indifferent_access
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def object_value(object, *keys)
|
|
64
|
+
keys.each do |key|
|
|
65
|
+
value = read_object_value(object, key)
|
|
66
|
+
return value unless value.nil?
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
57
69
|
end
|
|
58
70
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
def object_dig(object, *path)
|
|
72
|
+
if object.respond_to?(:dig)
|
|
73
|
+
begin
|
|
74
|
+
value = object.dig(*path)
|
|
75
|
+
return value unless value.nil?
|
|
76
|
+
rescue NameError, TypeError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
path.reduce(object) do |current, key|
|
|
82
|
+
return nil if current.nil?
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
read_object_value(current, key)
|
|
64
85
|
end
|
|
65
86
|
end
|
|
66
87
|
|
|
@@ -71,41 +92,62 @@ module LlmCostTracker
|
|
|
71
92
|
def patch_targets = []
|
|
72
93
|
|
|
73
94
|
def patch_target(constant_name, with:, methods:, optional: false)
|
|
74
|
-
|
|
95
|
+
{
|
|
96
|
+
constant_name: constant_name,
|
|
97
|
+
patch: with,
|
|
98
|
+
method_names: Array(methods),
|
|
99
|
+
optional: optional
|
|
100
|
+
}
|
|
75
101
|
end
|
|
76
102
|
|
|
103
|
+
module_function :object_value, :object_dig
|
|
104
|
+
|
|
77
105
|
private
|
|
78
106
|
|
|
107
|
+
def read_object_value(object, key)
|
|
108
|
+
return nil if object.nil?
|
|
109
|
+
return object[key] if object.try(:key?, key)
|
|
110
|
+
|
|
111
|
+
string_key = key.to_s
|
|
112
|
+
return object[string_key] if object.try(:key?, string_key)
|
|
113
|
+
|
|
114
|
+
value = object.try(key)
|
|
115
|
+
return value unless value.nil?
|
|
116
|
+
|
|
117
|
+
indexed_object_value(object, key)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def indexed_object_value(object, key)
|
|
121
|
+
object.try(:[], key)
|
|
122
|
+
rescue IndexError, NameError, TypeError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
module_function :read_object_value, :indexed_object_value
|
|
127
|
+
private_class_method :read_object_value, :indexed_object_value
|
|
128
|
+
|
|
79
129
|
def validate_contract!
|
|
80
|
-
problems =
|
|
130
|
+
problems = version_problems + target_problems
|
|
81
131
|
return if problems.empty?
|
|
82
132
|
|
|
83
133
|
raise Error, "#{integration_name} integration cannot be installed: #{problems.join('; ')}"
|
|
84
134
|
end
|
|
85
135
|
|
|
86
|
-
def contract_problems
|
|
87
|
-
version_problems + target_problems
|
|
88
|
-
end
|
|
89
|
-
|
|
90
136
|
def version_problems
|
|
91
137
|
return [] unless minimum_version
|
|
92
138
|
|
|
93
139
|
name = integration_name.to_s
|
|
94
|
-
version =
|
|
140
|
+
version = Gem.loaded_specs[integration_name.to_s]&.version || constant_version
|
|
95
141
|
return ["#{name} >= #{minimum_version} is required, but #{name} is not loaded"] unless version
|
|
96
142
|
return [] if version >= Gem::Version.new(minimum_version)
|
|
97
143
|
|
|
98
144
|
["#{name} >= #{minimum_version} is required, detected #{version}"]
|
|
99
145
|
end
|
|
100
146
|
|
|
101
|
-
def installed_version
|
|
102
|
-
Gem.loaded_specs[integration_name.to_s]&.version || constant_version
|
|
103
|
-
end
|
|
104
|
-
|
|
105
147
|
def constant_version
|
|
106
148
|
return nil unless version_constant
|
|
107
149
|
|
|
108
|
-
value =
|
|
150
|
+
value = version_constant.to_s.safe_constantize
|
|
109
151
|
value ? Gem::Version.new(value.to_s) : nil
|
|
110
152
|
rescue ArgumentError
|
|
111
153
|
nil
|
|
@@ -113,31 +155,28 @@ module LlmCostTracker
|
|
|
113
155
|
|
|
114
156
|
def target_problems
|
|
115
157
|
patch_targets.flat_map do |target|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
next [
|
|
158
|
+
constant_name = target.fetch(:constant_name)
|
|
159
|
+
target_class = constant_name.to_s.safe_constantize
|
|
160
|
+
next [] if target_class.nil? && target.fetch(:optional)
|
|
161
|
+
next ["#{constant_name} is not loaded"] unless target_class
|
|
119
162
|
|
|
120
163
|
missing_methods(target_class, target)
|
|
121
164
|
end
|
|
122
165
|
end
|
|
123
166
|
|
|
124
167
|
def missing_methods(target_class, target)
|
|
125
|
-
target.method_names.filter_map do |method_name|
|
|
168
|
+
target.fetch(:method_names).filter_map do |method_name|
|
|
126
169
|
next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
|
|
127
170
|
|
|
128
|
-
"#{target.constant_name}##{method_name} is not available"
|
|
171
|
+
"#{target.fetch(:constant_name)}##{method_name} is not available"
|
|
129
172
|
end
|
|
130
173
|
end
|
|
131
174
|
|
|
132
175
|
def install_patch(target, patch)
|
|
133
|
-
return if
|
|
176
|
+
return if target&.ancestors&.include?(patch)
|
|
134
177
|
|
|
135
178
|
target.prepend(patch)
|
|
136
179
|
end
|
|
137
|
-
|
|
138
|
-
def patch_installed?(target, patch)
|
|
139
|
-
target&.ancestors&.include?(patch)
|
|
140
|
-
end
|
|
141
180
|
end
|
|
142
181
|
end
|
|
143
182
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "../capture/stream_collector"
|
|
5
|
+
require_relative "../capture/stream_tracker"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
7
8
|
module Integrations
|
|
@@ -9,11 +10,17 @@ module LlmCostTracker
|
|
|
9
10
|
extend Base
|
|
10
11
|
|
|
11
12
|
class << self
|
|
12
|
-
def integration_name
|
|
13
|
+
def integration_name
|
|
14
|
+
:openai
|
|
15
|
+
end
|
|
13
16
|
|
|
14
|
-
def minimum_version
|
|
17
|
+
def minimum_version
|
|
18
|
+
"0.59.0"
|
|
19
|
+
end
|
|
15
20
|
|
|
16
|
-
def version_constant
|
|
21
|
+
def version_constant
|
|
22
|
+
"OpenAI::VERSION"
|
|
23
|
+
end
|
|
17
24
|
|
|
18
25
|
def patch_targets
|
|
19
26
|
[
|
|
@@ -34,67 +41,66 @@ module LlmCostTracker
|
|
|
34
41
|
return unless active?
|
|
35
42
|
|
|
36
43
|
record_safely do
|
|
37
|
-
usage =
|
|
44
|
+
usage = object_value(response, :usage)
|
|
38
45
|
next unless usage
|
|
39
46
|
|
|
40
|
-
input_tokens =
|
|
41
|
-
output_tokens =
|
|
47
|
+
input_tokens = object_value(usage, :input_tokens, :prompt_tokens)
|
|
48
|
+
output_tokens = object_value(usage, :output_tokens, :completion_tokens)
|
|
42
49
|
next if input_tokens.nil? && output_tokens.nil?
|
|
43
50
|
|
|
44
|
-
|
|
51
|
+
cache_read = cache_read_input_tokens(usage)
|
|
45
52
|
LlmCostTracker::Tracker.record(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
capture: UsageCapture.build(
|
|
54
|
+
provider: "openai",
|
|
55
|
+
model: object_value(response, :model) || request[:model],
|
|
56
|
+
pricing_mode: object_value(response, :service_tier) || request[:service_tier],
|
|
57
|
+
token_usage: TokenUsage.build(
|
|
58
|
+
input_tokens: regular_input_tokens(input_tokens, cache_read),
|
|
59
|
+
output_tokens: output_tokens.to_i,
|
|
60
|
+
cache_read_input_tokens: cache_read,
|
|
61
|
+
hidden_output_tokens: hidden_output_tokens(usage)
|
|
62
|
+
),
|
|
63
|
+
usage_source: :sdk_response,
|
|
64
|
+
provider_response_id: object_value(response, :id)
|
|
65
|
+
),
|
|
66
|
+
latency_ms: latency_ms
|
|
54
67
|
)
|
|
55
68
|
end
|
|
56
69
|
end
|
|
57
70
|
|
|
58
|
-
def usage_metadata(usage)
|
|
59
|
-
{
|
|
60
|
-
cache_read_input_tokens: cache_read_input_tokens(usage),
|
|
61
|
-
hidden_output_tokens: hidden_output_tokens(usage)
|
|
62
|
-
}
|
|
63
|
-
end
|
|
64
|
-
|
|
65
71
|
def cache_read_input_tokens(usage)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
72
|
+
(
|
|
73
|
+
object_dig(usage, :input_tokens_details, :cached_tokens) ||
|
|
74
|
+
object_dig(usage, :prompt_tokens_details, :cached_tokens)
|
|
75
|
+
).to_i
|
|
70
76
|
end
|
|
71
77
|
|
|
72
78
|
def hidden_output_tokens(usage)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
79
|
+
(
|
|
80
|
+
object_dig(usage, :output_tokens_details, :reasoning_tokens) ||
|
|
81
|
+
object_dig(usage, :completion_tokens_details, :reasoning_tokens)
|
|
82
|
+
).to_i
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
def regular_input_tokens(input_tokens, cache_read)
|
|
80
|
-
[
|
|
86
|
+
[input_tokens.to_i - cache_read.to_i, 0].max
|
|
81
87
|
end
|
|
82
88
|
|
|
83
89
|
def track_stream(stream, collector:)
|
|
84
90
|
return stream unless active?
|
|
85
91
|
|
|
86
|
-
StreamTracker.
|
|
87
|
-
stream,
|
|
92
|
+
LlmCostTracker::Capture::StreamTracker.new(
|
|
93
|
+
stream: stream,
|
|
88
94
|
collector: collector,
|
|
89
95
|
active: -> { active? },
|
|
90
96
|
finish: ->(errored:) { finish_stream(collector, errored: errored) }
|
|
91
|
-
)
|
|
97
|
+
).wrap
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
def stream_collector(request)
|
|
95
|
-
LlmCostTracker::StreamCollector.new(
|
|
101
|
+
LlmCostTracker::Capture::StreamCollector.new(
|
|
96
102
|
provider: "openai",
|
|
97
|
-
model: request[:model]
|
|
103
|
+
model: request[:model]
|
|
98
104
|
)
|
|
99
105
|
end
|
|
100
106
|
|
|
@@ -8,11 +8,17 @@ module LlmCostTracker
|
|
|
8
8
|
extend Base
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
|
-
def integration_name
|
|
11
|
+
def integration_name
|
|
12
|
+
:ruby_llm
|
|
13
|
+
end
|
|
12
14
|
|
|
13
|
-
def minimum_version
|
|
15
|
+
def minimum_version
|
|
16
|
+
"1.14.1"
|
|
17
|
+
end
|
|
14
18
|
|
|
15
|
-
def version_constant
|
|
19
|
+
def version_constant
|
|
20
|
+
"RubyLLM::VERSION"
|
|
21
|
+
end
|
|
16
22
|
|
|
17
23
|
def patch_targets
|
|
18
24
|
[
|
|
@@ -63,59 +69,63 @@ module LlmCostTracker
|
|
|
63
69
|
return unless active?
|
|
64
70
|
|
|
65
71
|
record_safely do
|
|
66
|
-
input_tokens =
|
|
67
|
-
output_tokens =
|
|
72
|
+
input_tokens = object_value(response, :input_tokens)
|
|
73
|
+
output_tokens = object_value(response, :output_tokens) if output_tokens.nil?
|
|
68
74
|
next if input_tokens.nil? && output_tokens.nil?
|
|
69
75
|
|
|
70
|
-
cache_read =
|
|
76
|
+
cache_read = object_value(response, :cached_tokens).to_i
|
|
77
|
+
hidden_output = object_value(response, :thinking_tokens, :reasoning_tokens).to_i
|
|
71
78
|
|
|
72
79
|
LlmCostTracker::Tracker.record(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
capture: UsageCapture.build(
|
|
81
|
+
provider: provider,
|
|
82
|
+
model: model,
|
|
83
|
+
pricing_mode: pricing_mode(response),
|
|
84
|
+
token_usage: TokenUsage.build(
|
|
85
|
+
input_tokens: regular_input_tokens(input_tokens, cache_read),
|
|
86
|
+
output_tokens: output_tokens.to_i,
|
|
87
|
+
cache_read_input_tokens: cache_read,
|
|
88
|
+
cache_write_input_tokens: object_value(response, :cache_creation_tokens).to_i,
|
|
89
|
+
hidden_output_tokens: hidden_output
|
|
90
|
+
),
|
|
91
|
+
stream: stream,
|
|
92
|
+
usage_source: :ruby_llm,
|
|
93
|
+
provider_response_id: provider_response_id(response)
|
|
94
|
+
),
|
|
95
|
+
latency_ms: latency_ms
|
|
82
96
|
)
|
|
83
97
|
end
|
|
84
98
|
end
|
|
85
99
|
|
|
86
|
-
def usage_metadata(response, cache_read)
|
|
87
|
-
{
|
|
88
|
-
cache_read_input_tokens: cache_read,
|
|
89
|
-
cache_write_input_tokens: ObjectReader.integer(ObjectReader.first(response, :cache_creation_tokens)),
|
|
90
|
-
hidden_output_tokens: ObjectReader.integer(
|
|
91
|
-
ObjectReader.first(response, :thinking_tokens, :reasoning_tokens)
|
|
92
|
-
)
|
|
93
|
-
}
|
|
94
|
-
end
|
|
95
|
-
|
|
96
100
|
def regular_input_tokens(input_tokens, cache_read)
|
|
97
|
-
[
|
|
101
|
+
[input_tokens.to_i - cache_read.to_i, 0].max
|
|
98
102
|
end
|
|
99
103
|
|
|
100
104
|
def provider_slug(provider)
|
|
101
|
-
|
|
105
|
+
object_value(provider, :slug).to_s
|
|
102
106
|
end
|
|
103
107
|
|
|
104
108
|
def model_id(object)
|
|
105
109
|
return nil if object.nil?
|
|
106
110
|
|
|
107
|
-
value =
|
|
111
|
+
value = object_value(object, :id, :model_id, :model)
|
|
108
112
|
value ||= object if object.is_a?(String) || object.is_a?(Symbol)
|
|
109
113
|
value&.to_s
|
|
110
114
|
end
|
|
111
115
|
|
|
112
116
|
def response_model_id(object)
|
|
113
|
-
value =
|
|
117
|
+
value = object_value(object, :model_id, :model)
|
|
114
118
|
value&.to_s
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
def provider_response_id(response)
|
|
118
|
-
|
|
122
|
+
object_value(response, :id, :provider_response_id) || object_dig(response, :raw, :id)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def pricing_mode(response)
|
|
126
|
+
object_value(response, :pricing_mode, :service_tier) ||
|
|
127
|
+
object_dig(response, :raw, :pricing_mode) ||
|
|
128
|
+
object_dig(response, :raw, :service_tier)
|
|
119
129
|
end
|
|
120
130
|
end
|
|
121
131
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "integrations/openai"
|
|
5
|
+
require_relative "integrations/anthropic"
|
|
6
|
+
require_relative "integrations/ruby_llm"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Integrations
|
|
10
|
+
AVAILABLE = {
|
|
11
|
+
openai: Openai,
|
|
12
|
+
anthropic: Anthropic,
|
|
13
|
+
ruby_llm: RubyLlm
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def install!(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
19
|
+
normalize(names).each { |name| fetch(name).install }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def checks(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
23
|
+
return [Base::Result.new(:integrations, :ok, "no SDK integrations enabled")] if names.empty?
|
|
24
|
+
|
|
25
|
+
normalize(names).map { |name| fetch(name).status }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def normalize(names)
|
|
29
|
+
Array(names).flatten.map(&:to_sym).uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fetch(name)
|
|
33
|
+
AVAILABLE.fetch(name.to_sym) do
|
|
34
|
+
message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
|
|
35
|
+
raise LlmCostTracker::Error, message
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def names
|
|
40
|
+
AVAILABLE.keys
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|