llm_cost_tracker 0.2.0 → 0.3.1
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 +36 -0
- data/README.md +124 -68
- data/Rakefile +2 -0
- data/app/assets/llm_cost_tracker/application.css +1 -4
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +26 -24
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
- data/app/services/llm_cost_tracker/pagination.rb +1 -9
- data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
- data/app/views/llm_cost_tracker/calls/index.html.erb +23 -13
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +11 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +78 -10
- data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/assets.rb +6 -11
- data/lib/llm_cost_tracker/configuration.rb +78 -43
- data/lib/llm_cost_tracker/event.rb +3 -0
- data/lib/llm_cost_tracker/event_metadata.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +6 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
- data/lib/llm_cost_tracker/llm_api_call.rb +14 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +58 -9
- data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
- data/lib/llm_cost_tracker/parsed_usage.rb +18 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +98 -1
- data/lib/llm_cost_tracker/parsers/base.rb +17 -5
- data/lib/llm_cost_tracker/parsers/gemini.rb +83 -6
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -5
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +69 -1
- data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
- data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
- data/lib/llm_cost_tracker/price_registry.rb +23 -8
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
- data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
- data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
- data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
- data/lib/llm_cost_tracker/price_sync.rb +142 -0
- data/lib/llm_cost_tracker/pricing.rb +0 -11
- data/lib/llm_cost_tracker/railtie.rb +0 -1
- data/lib/llm_cost_tracker/report.rb +0 -5
- data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -9
- data/lib/llm_cost_tracker/stream_collector.rb +162 -0
- data/lib/llm_cost_tracker/tags_column.rb +12 -0
- data/lib/llm_cost_tracker/tracker.rb +23 -12
- data/lib/llm_cost_tracker/value_helpers.rb +40 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +48 -35
- data/lib/tasks/llm_cost_tracker.rake +116 -0
- data/llm_cost_tracker.gemspec +8 -6
- metadata +30 -8
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
require_relative "price_sync/fetcher"
|
|
6
|
+
require_relative "price_sync/raw_price"
|
|
7
|
+
require_relative "price_sync/source"
|
|
8
|
+
require_relative "price_sync/source_result"
|
|
9
|
+
require_relative "price_sync/registry_loader"
|
|
10
|
+
require_relative "price_sync/registry_writer"
|
|
11
|
+
require_relative "price_sync/refresh_plan_builder"
|
|
12
|
+
require_relative "price_sync/model_catalog"
|
|
13
|
+
require_relative "price_sync/merger"
|
|
14
|
+
require_relative "price_sync/validator"
|
|
15
|
+
require_relative "price_sync/sources/litellm"
|
|
16
|
+
require_relative "price_sync/sources/open_router"
|
|
17
|
+
|
|
18
|
+
module LlmCostTracker
|
|
19
|
+
module PriceSync
|
|
20
|
+
DEFAULT_OUTPUT_PATH = PriceRegistry::DEFAULT_PRICES_PATH
|
|
21
|
+
|
|
22
|
+
SourceUsage = Data.define(:prices_count, :source_version)
|
|
23
|
+
SyncResult = Data.define(
|
|
24
|
+
:path,
|
|
25
|
+
:updated_models,
|
|
26
|
+
:changes,
|
|
27
|
+
:orphaned_models,
|
|
28
|
+
:failed_sources,
|
|
29
|
+
:discrepancies,
|
|
30
|
+
:rejected,
|
|
31
|
+
:flagged,
|
|
32
|
+
:sources_used,
|
|
33
|
+
:written
|
|
34
|
+
)
|
|
35
|
+
CheckResult = Data.define(
|
|
36
|
+
:path,
|
|
37
|
+
:changes,
|
|
38
|
+
:orphaned_models,
|
|
39
|
+
:failed_sources,
|
|
40
|
+
:discrepancies,
|
|
41
|
+
:rejected,
|
|
42
|
+
:flagged,
|
|
43
|
+
:sources_used,
|
|
44
|
+
:up_to_date
|
|
45
|
+
)
|
|
46
|
+
RefreshPlan = Data.define(
|
|
47
|
+
:path,
|
|
48
|
+
:registry,
|
|
49
|
+
:updated_registry,
|
|
50
|
+
:accepted,
|
|
51
|
+
:changes,
|
|
52
|
+
:orphaned_models,
|
|
53
|
+
:failed_sources,
|
|
54
|
+
:discrepancies,
|
|
55
|
+
:rejected,
|
|
56
|
+
:flagged,
|
|
57
|
+
:sources_used,
|
|
58
|
+
:source_results
|
|
59
|
+
) do
|
|
60
|
+
def refresh_succeeded?
|
|
61
|
+
source_results.any? { |_source, result| result.prices.any? }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def up_to_date?
|
|
65
|
+
changes.empty? && failed_sources.empty? && rejected.empty?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
|
|
71
|
+
fetcher: Fetcher.new, today: Date.today)
|
|
72
|
+
plan = RefreshPlanBuilder.new(sources: sources).call(
|
|
73
|
+
path: path,
|
|
74
|
+
seed_path: seed_path,
|
|
75
|
+
fetcher: fetcher,
|
|
76
|
+
today: today
|
|
77
|
+
)
|
|
78
|
+
raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
|
|
79
|
+
|
|
80
|
+
written = !preview && plan.refresh_succeeded?
|
|
81
|
+
RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
|
|
82
|
+
|
|
83
|
+
SyncResult.new(
|
|
84
|
+
path: plan.path,
|
|
85
|
+
updated_models: plan.changes.keys.sort,
|
|
86
|
+
changes: plan.changes,
|
|
87
|
+
orphaned_models: plan.orphaned_models,
|
|
88
|
+
failed_sources: plan.failed_sources,
|
|
89
|
+
discrepancies: plan.discrepancies,
|
|
90
|
+
rejected: plan.rejected,
|
|
91
|
+
flagged: plan.flagged,
|
|
92
|
+
sources_used: plan.sources_used,
|
|
93
|
+
written: written
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
|
|
98
|
+
plan = RefreshPlanBuilder.new(sources: sources).call(
|
|
99
|
+
path: path,
|
|
100
|
+
seed_path: seed_path,
|
|
101
|
+
fetcher: fetcher,
|
|
102
|
+
today: today
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
CheckResult.new(
|
|
106
|
+
path: plan.path,
|
|
107
|
+
changes: plan.changes,
|
|
108
|
+
orphaned_models: plan.orphaned_models,
|
|
109
|
+
failed_sources: plan.failed_sources,
|
|
110
|
+
discrepancies: plan.discrepancies,
|
|
111
|
+
rejected: plan.rejected,
|
|
112
|
+
flagged: plan.flagged,
|
|
113
|
+
sources_used: plan.sources_used,
|
|
114
|
+
up_to_date: plan.up_to_date?
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def sources
|
|
121
|
+
[Sources::Litellm.new, Sources::OpenRouter.new]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def strict_sync_failure?(plan, strict:)
|
|
125
|
+
strict && (plan.failed_sources.any? || plan.rejected.any?)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def strict_failure_message(plan)
|
|
129
|
+
messages = []
|
|
130
|
+
if plan.failed_sources.any?
|
|
131
|
+
details = plan.failed_sources.map { |source, message| "#{source}: #{message}" }.join(", ")
|
|
132
|
+
messages << "source failures: #{details}"
|
|
133
|
+
end
|
|
134
|
+
if plan.rejected.any?
|
|
135
|
+
details = plan.rejected.map { |issue| "#{issue.model} (#{issue.reason})" }.join(", ")
|
|
136
|
+
messages << "validator rejections: #{details}"
|
|
137
|
+
end
|
|
138
|
+
"Price sync failed in strict mode: #{messages.join('; ')}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -3,21 +3,11 @@
|
|
|
3
3
|
require "monitor"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
|
-
# Calculates costs from price entries expressed in USD per 1M tokens.
|
|
7
6
|
module Pricing
|
|
8
7
|
PRICES = PriceRegistry.builtin_prices
|
|
9
8
|
MUTEX = Monitor.new
|
|
10
9
|
|
|
11
10
|
class << self
|
|
12
|
-
# Estimate model cost from token counts.
|
|
13
|
-
#
|
|
14
|
-
# @param model [String] Provider model identifier.
|
|
15
|
-
# @param input_tokens [Integer] Input token count, including cached tokens if reported that way.
|
|
16
|
-
# @param output_tokens [Integer] Output token count.
|
|
17
|
-
# @param cached_input_tokens [Integer] OpenAI-style cached input tokens.
|
|
18
|
-
# @param cache_read_input_tokens [Integer] Anthropic-style cache read tokens.
|
|
19
|
-
# @param cache_creation_input_tokens [Integer] Anthropic-style cache creation tokens.
|
|
20
|
-
# @return [LlmCostTracker::Cost, nil] nil when no price is configured for the model.
|
|
21
11
|
def cost_for(model:, input_tokens:, output_tokens:, cached_input_tokens: 0,
|
|
22
12
|
cache_read_input_tokens: 0, cache_creation_input_tokens: 0)
|
|
23
13
|
prices = lookup(model)
|
|
@@ -111,7 +101,6 @@ module LlmCostTracker
|
|
|
111
101
|
model.to_s.split("/").last
|
|
112
102
|
end
|
|
113
103
|
|
|
114
|
-
# Try to match model names like "gpt-4o-2024-08-06" to "gpt-4o".
|
|
115
104
|
def fuzzy_match(model, normalized_model, table)
|
|
116
105
|
sorted_price_keys(table).each do |key|
|
|
117
106
|
return table[key] if model.start_with?(key) || normalized_model.start_with?(key)
|
|
@@ -8,11 +8,6 @@ module LlmCostTracker
|
|
|
8
8
|
DEFAULT_DAYS = ReportData::DEFAULT_DAYS
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
|
-
# Render a terminal-friendly cost report from ActiveRecord storage.
|
|
12
|
-
#
|
|
13
|
-
# @param days [Integer] Number of trailing days to include.
|
|
14
|
-
# @param now [Time] Report end time.
|
|
15
|
-
# @return [String]
|
|
16
11
|
def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
17
12
|
ReportFormatter.new(data(days: days, now: now, tag_breakdowns: tag_breakdowns)).to_s
|
|
18
13
|
rescue LoadError => e
|
|
@@ -19,22 +19,23 @@ module LlmCostTracker
|
|
|
19
19
|
tags: tags_for_storage(tags),
|
|
20
20
|
tracked_at: event.tracked_at
|
|
21
21
|
}
|
|
22
|
-
attributes[:latency_ms] = event.latency_ms if
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
attributes[:latency_ms] = event.latency_ms if LlmCostTracker::LlmApiCall.latency_column?
|
|
23
|
+
attributes[:stream] = event.stream if LlmCostTracker::LlmApiCall.stream_column?
|
|
24
|
+
attributes[:usage_source] = event.usage_source if LlmCostTracker::LlmApiCall.usage_source_column?
|
|
25
|
+
if LlmCostTracker::LlmApiCall.provider_response_id_column?
|
|
26
|
+
attributes[:provider_response_id] = event.provider_response_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
LlmCostTracker::LlmApiCall.create!(attributes)
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
def monthly_total(time: Time.now.utc)
|
|
28
|
-
|
|
33
|
+
LlmCostTracker::LlmApiCall
|
|
29
34
|
.where(tracked_at: time.beginning_of_month..time)
|
|
30
35
|
.sum(:total_cost)
|
|
31
36
|
.to_f
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def model_class
|
|
35
|
-
LlmCostTracker::LlmApiCall
|
|
36
|
-
end
|
|
37
|
-
|
|
38
39
|
private
|
|
39
40
|
|
|
40
41
|
def stringify_tags(tags)
|
|
@@ -42,7 +43,7 @@ module LlmCostTracker
|
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def tags_for_storage(tags)
|
|
45
|
-
|
|
46
|
+
LlmCostTracker::LlmApiCall.tags_json_column? ? tags : tags.to_json
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def stringify_tag_value(value)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
require_relative "value_helpers"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
class StreamCollector
|
|
9
|
+
attr_reader :provider
|
|
10
|
+
|
|
11
|
+
def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, metadata: {})
|
|
12
|
+
@provider = provider.to_s
|
|
13
|
+
@model = model
|
|
14
|
+
@latency_ms = latency_ms
|
|
15
|
+
@provider_response_id = provider_response_id
|
|
16
|
+
@metadata = ValueHelpers.deep_dup(metadata || {})
|
|
17
|
+
@events = []
|
|
18
|
+
@explicit_usage = nil
|
|
19
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
@finished = false
|
|
21
|
+
@monitor = Monitor.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def model = @monitor.synchronize { @model }
|
|
25
|
+
|
|
26
|
+
def metadata = @monitor.synchronize { ValueHelpers.deep_dup(@metadata) }
|
|
27
|
+
|
|
28
|
+
def provider_response_id = @monitor.synchronize { @provider_response_id }
|
|
29
|
+
|
|
30
|
+
def model=(value)
|
|
31
|
+
@monitor.synchronize do
|
|
32
|
+
ensure_open!
|
|
33
|
+
@model = value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def provider_response_id=(value)
|
|
38
|
+
@monitor.synchronize do
|
|
39
|
+
ensure_open!
|
|
40
|
+
@provider_response_id = value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def event(data, type: nil)
|
|
45
|
+
@monitor.synchronize do
|
|
46
|
+
ensure_open!
|
|
47
|
+
@events << { event: type, data: ValueHelpers.deep_dup(data) } unless data.nil?
|
|
48
|
+
end
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
alias chunk event
|
|
52
|
+
|
|
53
|
+
def usage(input_tokens:, output_tokens:, **extra)
|
|
54
|
+
@monitor.synchronize do
|
|
55
|
+
ensure_open!
|
|
56
|
+
@explicit_usage = ValueHelpers.deep_dup(
|
|
57
|
+
extra.merge(
|
|
58
|
+
input_tokens: input_tokens.to_i,
|
|
59
|
+
output_tokens: output_tokens.to_i
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def finish!(errored: false)
|
|
67
|
+
snapshot = @monitor.synchronize do
|
|
68
|
+
return if @finished
|
|
69
|
+
|
|
70
|
+
@finished = true
|
|
71
|
+
{
|
|
72
|
+
events: ValueHelpers.deep_dup(@events),
|
|
73
|
+
explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
|
|
74
|
+
model: @model,
|
|
75
|
+
latency_ms: @latency_ms,
|
|
76
|
+
provider_response_id: @provider_response_id,
|
|
77
|
+
metadata: ValueHelpers.deep_dup(@metadata)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
parsed = build_parsed_usage(snapshot)
|
|
82
|
+
Tracker.record(
|
|
83
|
+
provider: parsed.provider,
|
|
84
|
+
model: parsed.model,
|
|
85
|
+
input_tokens: parsed.input_tokens,
|
|
86
|
+
output_tokens: parsed.output_tokens,
|
|
87
|
+
latency_ms: snapshot[:latency_ms] || elapsed_ms,
|
|
88
|
+
stream: true,
|
|
89
|
+
usage_source: parsed.usage_source,
|
|
90
|
+
provider_response_id: parsed.provider_response_id || snapshot[:provider_response_id],
|
|
91
|
+
metadata: error_metadata(errored).merge(snapshot[:metadata]).merge(parsed.metadata)
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def ensure_open!
|
|
98
|
+
return unless @finished
|
|
99
|
+
|
|
100
|
+
raise FrozenError, "can't modify finished LlmCostTracker::StreamCollector"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_parsed_usage(snapshot)
|
|
104
|
+
return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
|
|
105
|
+
|
|
106
|
+
parsed = Parsers::Registry.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
|
|
107
|
+
return finalize(parsed, snapshot) if parsed
|
|
108
|
+
|
|
109
|
+
build_unknown_usage(snapshot)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def finalize(parsed, snapshot)
|
|
113
|
+
parsed.with(
|
|
114
|
+
provider: @provider,
|
|
115
|
+
model: present_model(parsed.model) || snapshot[:model]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def present_model(value)
|
|
120
|
+
return nil if value.nil?
|
|
121
|
+
|
|
122
|
+
string = value.to_s
|
|
123
|
+
return nil if string.empty? || string == "unknown"
|
|
124
|
+
|
|
125
|
+
string
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_from_explicit_usage(snapshot)
|
|
129
|
+
explicit = snapshot[:explicit_usage]
|
|
130
|
+
input = explicit[:input_tokens]
|
|
131
|
+
output = explicit[:output_tokens]
|
|
132
|
+
extras = explicit.except(:input_tokens, :output_tokens)
|
|
133
|
+
|
|
134
|
+
ParsedUsage.build(
|
|
135
|
+
provider: @provider,
|
|
136
|
+
model: snapshot[:model],
|
|
137
|
+
input_tokens: input,
|
|
138
|
+
output_tokens: output,
|
|
139
|
+
total_tokens: input + output,
|
|
140
|
+
stream: true,
|
|
141
|
+
usage_source: :manual,
|
|
142
|
+
**extras
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_unknown_usage(snapshot)
|
|
147
|
+
ParsedUsage.build(
|
|
148
|
+
provider: @provider,
|
|
149
|
+
model: snapshot[:model],
|
|
150
|
+
input_tokens: 0,
|
|
151
|
+
output_tokens: 0,
|
|
152
|
+
total_tokens: 0,
|
|
153
|
+
stream: true,
|
|
154
|
+
usage_source: :unknown
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def error_metadata(errored) = errored ? { stream_errored: true } : {}
|
|
159
|
+
|
|
160
|
+
def elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -24,5 +24,17 @@ module LlmCostTracker
|
|
|
24
24
|
def latency_column?
|
|
25
25
|
columns_hash.key?("latency_ms")
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
def stream_column?
|
|
29
|
+
columns_hash.key?("stream")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def usage_source_column?
|
|
33
|
+
columns_hash.key?("usage_source")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def provider_response_id_column?
|
|
37
|
+
columns_hash.key?("provider_response_id")
|
|
38
|
+
end
|
|
27
39
|
end
|
|
28
40
|
end
|
|
@@ -6,21 +6,15 @@ module LlmCostTracker
|
|
|
6
6
|
class Tracker
|
|
7
7
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
8
8
|
|
|
9
|
+
USAGE_SOURCES = %i[response stream_final manual unknown].freeze
|
|
10
|
+
|
|
9
11
|
class << self
|
|
10
12
|
def enforce_budget!
|
|
11
13
|
Budget.enforce!
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# @param provider [String] Provider name.
|
|
17
|
-
# @param model [String] Model identifier.
|
|
18
|
-
# @param input_tokens [Integer] Input token count.
|
|
19
|
-
# @param output_tokens [Integer] Output token count.
|
|
20
|
-
# @param metadata [Hash] Attribution tags plus provider-specific usage metadata.
|
|
21
|
-
# @param latency_ms [Integer, nil] Optional latency in milliseconds.
|
|
22
|
-
# @return [LlmCostTracker::Event]
|
|
23
|
-
def record(provider:, model:, input_tokens:, output_tokens:, metadata: {}, latency_ms: nil)
|
|
16
|
+
def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
|
|
17
|
+
usage_source: nil, provider_response_id: nil, metadata: {})
|
|
24
18
|
usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
|
|
25
19
|
|
|
26
20
|
cost_data = Pricing.cost_for(
|
|
@@ -43,13 +37,14 @@ module LlmCostTracker
|
|
|
43
37
|
cost: cost_data,
|
|
44
38
|
tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)).freeze,
|
|
45
39
|
latency_ms: normalized_latency_ms(latency_ms),
|
|
40
|
+
stream: stream ? true : false,
|
|
41
|
+
usage_source: normalized_usage_source(usage_source),
|
|
42
|
+
provider_response_id: normalized_provider_response_id(provider_response_id),
|
|
46
43
|
tracked_at: Time.now.utc
|
|
47
44
|
)
|
|
48
45
|
|
|
49
|
-
# Emit ActiveSupport::Notifications event
|
|
50
46
|
ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
|
|
51
47
|
|
|
52
|
-
# Store based on backend
|
|
53
48
|
stored = store(event)
|
|
54
49
|
Budget.check!(event) unless stored == false
|
|
55
50
|
|
|
@@ -77,6 +72,8 @@ module LlmCostTracker
|
|
|
77
72
|
"tokens=#{event.input_tokens}+#{event.output_tokens} " \
|
|
78
73
|
"cost=#{log_cost_label(event)}"
|
|
79
74
|
message += " latency=#{event.latency_ms}ms" if event.latency_ms
|
|
75
|
+
message += " stream=#{event.stream}" if event.stream
|
|
76
|
+
message += " source=#{event.usage_source}" if event.usage_source
|
|
80
77
|
message += " tags=#{event.tags}" unless event.tags.empty?
|
|
81
78
|
|
|
82
79
|
Logging.log(config.log_level, message)
|
|
@@ -119,6 +116,20 @@ module LlmCostTracker
|
|
|
119
116
|
|
|
120
117
|
[latency_ms.to_i, 0].max
|
|
121
118
|
end
|
|
119
|
+
|
|
120
|
+
def normalized_usage_source(value)
|
|
121
|
+
return nil if value.nil?
|
|
122
|
+
|
|
123
|
+
symbol = value.to_sym
|
|
124
|
+
USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def normalized_provider_response_id(value)
|
|
128
|
+
return nil if value.nil?
|
|
129
|
+
|
|
130
|
+
string = value.to_s
|
|
131
|
+
string.empty? ? nil : string
|
|
132
|
+
end
|
|
122
133
|
end
|
|
123
134
|
end
|
|
124
135
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ValueHelpers
|
|
5
|
+
class << self
|
|
6
|
+
def deep_dup(value)
|
|
7
|
+
case value
|
|
8
|
+
when Hash
|
|
9
|
+
value.each_with_object({}) do |(key, nested_value), duplicated|
|
|
10
|
+
duplicated[deep_dup(key)] = deep_dup(nested_value)
|
|
11
|
+
end
|
|
12
|
+
when Array
|
|
13
|
+
value.map { |nested_value| deep_dup(nested_value) }
|
|
14
|
+
when String
|
|
15
|
+
value.dup
|
|
16
|
+
else
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def deep_freeze(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash
|
|
24
|
+
value.each do |key, nested_value|
|
|
25
|
+
deep_freeze(key)
|
|
26
|
+
deep_freeze(nested_value)
|
|
27
|
+
end
|
|
28
|
+
value.frozen? ? value : value.freeze
|
|
29
|
+
when Array
|
|
30
|
+
value.each { |nested_value| deep_freeze(nested_value) }
|
|
31
|
+
value.frozen? ? value : value.freeze
|
|
32
|
+
when String
|
|
33
|
+
value.frozen? ? value : value.freeze
|
|
34
|
+
else
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/llm_cost_tracker.rb
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support"
|
|
4
4
|
require "active_support/notifications"
|
|
5
|
+
require "monitor"
|
|
5
6
|
|
|
6
7
|
require_relative "llm_cost_tracker/version"
|
|
7
8
|
require_relative "llm_cost_tracker/configuration"
|
|
8
9
|
require_relative "llm_cost_tracker/errors"
|
|
9
10
|
require_relative "llm_cost_tracker/logging"
|
|
11
|
+
require_relative "llm_cost_tracker/parameter_hash"
|
|
10
12
|
require_relative "llm_cost_tracker/cost"
|
|
11
13
|
require_relative "llm_cost_tracker/event"
|
|
12
14
|
require_relative "llm_cost_tracker/parsed_usage"
|
|
13
15
|
require_relative "llm_cost_tracker/price_registry"
|
|
16
|
+
require_relative "llm_cost_tracker/price_sync"
|
|
14
17
|
require_relative "llm_cost_tracker/pricing"
|
|
15
18
|
require_relative "llm_cost_tracker/parsers/base"
|
|
16
19
|
require_relative "llm_cost_tracker/parsers/openai_usage"
|
|
@@ -18,6 +21,7 @@ require_relative "llm_cost_tracker/parsers/openai"
|
|
|
18
21
|
require_relative "llm_cost_tracker/parsers/openai_compatible"
|
|
19
22
|
require_relative "llm_cost_tracker/parsers/anthropic"
|
|
20
23
|
require_relative "llm_cost_tracker/parsers/gemini"
|
|
24
|
+
require_relative "llm_cost_tracker/parsers/sse"
|
|
21
25
|
require_relative "llm_cost_tracker/parsers/registry"
|
|
22
26
|
require_relative "llm_cost_tracker/middleware/faraday"
|
|
23
27
|
require_relative "llm_cost_tracker/budget"
|
|
@@ -34,71 +38,80 @@ require_relative "llm_cost_tracker/report_formatter"
|
|
|
34
38
|
require_relative "llm_cost_tracker/report"
|
|
35
39
|
|
|
36
40
|
module LlmCostTracker
|
|
37
|
-
|
|
38
|
-
attr_writer :configuration
|
|
41
|
+
CONFIGURATION_MUTEX = Monitor.new
|
|
39
42
|
|
|
43
|
+
class << self
|
|
40
44
|
def configuration
|
|
41
|
-
@configuration ||= Configuration.new
|
|
45
|
+
CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
# Configure the gem once during application boot.
|
|
45
|
-
#
|
|
46
|
-
# @yieldparam configuration [LlmCostTracker::Configuration]
|
|
47
|
-
# @return [void]
|
|
48
48
|
def configure
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
config = CONFIGURATION_MUTEX.synchronize do
|
|
50
|
+
current = @configuration || Configuration.new
|
|
51
|
+
current = current.dup_for_configuration if current.finalized?
|
|
52
|
+
@configuration = current
|
|
53
|
+
yield(current)
|
|
54
|
+
current.normalize_openai_compatible_providers!
|
|
55
|
+
current.finalize!
|
|
56
|
+
current
|
|
57
|
+
end
|
|
58
|
+
warn_for_configuration!(config)
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
def reset_configuration!
|
|
55
|
-
@configuration = Configuration.new
|
|
62
|
+
CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def enforce_budget!
|
|
66
|
+
Tracker.enforce_budget!
|
|
56
67
|
end
|
|
57
68
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# provider: :openai,
|
|
62
|
-
# model: "gpt-4o",
|
|
63
|
-
# input_tokens: 150,
|
|
64
|
-
# output_tokens: 50,
|
|
65
|
-
# feature: "chat",
|
|
66
|
-
# user_id: current_user.id
|
|
67
|
-
# )
|
|
68
|
-
#
|
|
69
|
-
# @param provider [String, Symbol] Provider name, such as :openai or :anthropic.
|
|
70
|
-
# @param model [String] Provider model identifier.
|
|
71
|
-
# @param input_tokens [Integer] Billed input token count.
|
|
72
|
-
# @param output_tokens [Integer] Billed output token count.
|
|
73
|
-
# @param latency_ms [Integer, nil] Optional request latency in milliseconds.
|
|
74
|
-
# @param metadata [Hash] Attribution tags and provider-specific usage metadata.
|
|
75
|
-
# @return [LlmCostTracker::Event] The tracked event.
|
|
76
|
-
def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, **metadata)
|
|
69
|
+
def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false, usage_source: :manual,
|
|
70
|
+
enforce_budget: false, provider_response_id: nil, **metadata)
|
|
71
|
+
enforce_budget! if enforce_budget
|
|
77
72
|
Tracker.record(
|
|
78
73
|
provider: provider.to_s,
|
|
79
74
|
model: model,
|
|
80
75
|
input_tokens: input_tokens,
|
|
81
76
|
output_tokens: output_tokens,
|
|
82
77
|
latency_ms: latency_ms,
|
|
78
|
+
stream: stream,
|
|
79
|
+
usage_source: usage_source,
|
|
80
|
+
provider_response_id: provider_response_id,
|
|
81
|
+
metadata: metadata
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, provider_response_id: nil, **metadata)
|
|
86
|
+
require_relative "llm_cost_tracker/stream_collector"
|
|
87
|
+
enforce_budget! if enforce_budget
|
|
88
|
+
collector = StreamCollector.new(
|
|
89
|
+
provider: provider.to_s,
|
|
90
|
+
model: model,
|
|
91
|
+
latency_ms: latency_ms,
|
|
92
|
+
provider_response_id: provider_response_id,
|
|
83
93
|
metadata: metadata
|
|
84
94
|
)
|
|
95
|
+
yield collector
|
|
96
|
+
collector.finish!
|
|
97
|
+
rescue StandardError
|
|
98
|
+
collector&.finish!(errored: true)
|
|
99
|
+
raise
|
|
85
100
|
end
|
|
86
101
|
|
|
87
102
|
private
|
|
88
103
|
|
|
89
|
-
def warn_for_configuration!
|
|
90
|
-
return unless
|
|
91
|
-
return if
|
|
104
|
+
def warn_for_configuration!(config = configuration)
|
|
105
|
+
return unless config.budget_exceeded_behavior == :block_requests
|
|
106
|
+
return if config.active_record?
|
|
92
107
|
|
|
93
108
|
Logging.warn(":block_requests requires storage_backend = :active_record; preflight blocking will be skipped.")
|
|
94
109
|
end
|
|
95
110
|
end
|
|
96
111
|
end
|
|
97
112
|
|
|
98
|
-
# Load Railtie if Rails is present
|
|
99
113
|
require_relative "llm_cost_tracker/railtie" if defined?(Rails::Railtie)
|
|
100
114
|
|
|
101
|
-
# Auto-register Faraday middleware
|
|
102
115
|
if defined?(Faraday)
|
|
103
116
|
Faraday::Middleware.register_middleware(
|
|
104
117
|
llm_cost_tracker: LlmCostTracker::Middleware::Faraday
|