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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "value_helpers"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
class Configuration
|
|
7
|
-
# Hostname => provider name for OpenAI-compatible APIs.
|
|
8
8
|
OPENAI_COMPATIBLE_PROVIDERS = {
|
|
9
9
|
"openrouter.ai" => "openrouter",
|
|
10
10
|
"api.deepseek.com" => "deepseek"
|
|
@@ -14,22 +14,32 @@ module LlmCostTracker
|
|
|
14
14
|
STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
|
|
15
15
|
STORAGE_BACKENDS = %i[log active_record custom].freeze
|
|
16
16
|
UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
|
|
17
|
+
SHARED_SCALAR_ATTRIBUTES = %i[
|
|
18
|
+
enabled
|
|
19
|
+
custom_storage
|
|
20
|
+
on_budget_exceeded
|
|
21
|
+
monthly_budget
|
|
22
|
+
log_level
|
|
23
|
+
prices_file
|
|
24
|
+
].freeze
|
|
25
|
+
SHARED_ENUM_ATTRIBUTES = {
|
|
26
|
+
storage_backend: [STORAGE_BACKENDS, :log],
|
|
27
|
+
budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
|
|
28
|
+
storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
|
|
29
|
+
unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
|
|
30
|
+
}.freeze
|
|
17
31
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
:storage_backend, # :log, :active_record, :custom
|
|
30
|
-
:storage_error_behavior, # :ignore, :warn, :raise
|
|
31
|
-
:unknown_pricing_behavior, # :ignore, :warn, :raise
|
|
32
|
-
:openai_compatible_providers
|
|
32
|
+
attr_reader(
|
|
33
|
+
*SHARED_SCALAR_ATTRIBUTES,
|
|
34
|
+
:budget_exceeded_behavior,
|
|
35
|
+
:default_tags,
|
|
36
|
+
:pricing_overrides,
|
|
37
|
+
:report_tag_breakdowns,
|
|
38
|
+
:storage_backend,
|
|
39
|
+
:storage_error_behavior,
|
|
40
|
+
:unknown_pricing_behavior,
|
|
41
|
+
:openai_compatible_providers
|
|
42
|
+
)
|
|
33
43
|
|
|
34
44
|
def initialize
|
|
35
45
|
@enabled = true
|
|
@@ -46,55 +56,74 @@ module LlmCostTracker
|
|
|
46
56
|
@pricing_overrides = {}
|
|
47
57
|
@report_tag_breakdowns = []
|
|
48
58
|
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
59
|
+
@finalized = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def default_tags=(value)
|
|
63
|
+
ensure_shared_configuration_mutable!
|
|
64
|
+
@default_tags = value
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
def openai_compatible_providers=(providers)
|
|
68
|
+
ensure_shared_configuration_mutable!
|
|
52
69
|
@openai_compatible_providers = normalize_openai_compatible_providers(providers)
|
|
53
70
|
end
|
|
54
71
|
|
|
55
|
-
def
|
|
56
|
-
|
|
72
|
+
def pricing_overrides=(value)
|
|
73
|
+
ensure_shared_configuration_mutable!
|
|
74
|
+
@pricing_overrides = value
|
|
57
75
|
end
|
|
58
76
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
value,
|
|
63
|
-
BUDGET_EXCEEDED_BEHAVIORS,
|
|
64
|
-
default: :notify
|
|
65
|
-
)
|
|
77
|
+
def report_tag_breakdowns=(value)
|
|
78
|
+
ensure_shared_configuration_mutable!
|
|
79
|
+
@report_tag_breakdowns = value
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
value
|
|
72
|
-
|
|
73
|
-
default: :warn
|
|
74
|
-
)
|
|
82
|
+
SHARED_SCALAR_ATTRIBUTES.each do |name|
|
|
83
|
+
define_method("#{name}=") do |value|
|
|
84
|
+
ensure_shared_configuration_mutable!
|
|
85
|
+
instance_variable_set(:"@#{name}", value)
|
|
86
|
+
end
|
|
75
87
|
end
|
|
76
88
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
value,
|
|
81
|
-
|
|
82
|
-
default: :warn
|
|
83
|
-
)
|
|
89
|
+
SHARED_ENUM_ATTRIBUTES.each do |name, (allowed, default)|
|
|
90
|
+
define_method("#{name}=") do |value|
|
|
91
|
+
ensure_shared_configuration_mutable!
|
|
92
|
+
instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default: default))
|
|
93
|
+
end
|
|
84
94
|
end
|
|
85
95
|
|
|
86
96
|
def normalize_openai_compatible_providers!
|
|
87
97
|
self.openai_compatible_providers = openai_compatible_providers
|
|
88
98
|
end
|
|
89
99
|
|
|
90
|
-
def
|
|
91
|
-
|
|
100
|
+
def finalize!
|
|
101
|
+
@default_tags = ValueHelpers.deep_freeze(@default_tags || {})
|
|
102
|
+
@pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
|
|
103
|
+
@report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
|
|
104
|
+
@openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
|
|
105
|
+
@finalized = true
|
|
106
|
+
self
|
|
92
107
|
end
|
|
93
108
|
|
|
94
|
-
def
|
|
95
|
-
|
|
109
|
+
def finalized? = @finalized
|
|
110
|
+
|
|
111
|
+
def dup_for_configuration
|
|
112
|
+
copy = dup
|
|
113
|
+
copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
|
|
114
|
+
copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
|
|
115
|
+
copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
|
|
116
|
+
copy.instance_variable_set(
|
|
117
|
+
:@openai_compatible_providers,
|
|
118
|
+
ValueHelpers.deep_dup(@openai_compatible_providers || {})
|
|
119
|
+
)
|
|
120
|
+
copy.instance_variable_set(:@finalized, false)
|
|
121
|
+
copy
|
|
96
122
|
end
|
|
97
123
|
|
|
124
|
+
def active_record? = storage_backend == :active_record
|
|
125
|
+
def log? = storage_backend == :log
|
|
126
|
+
|
|
98
127
|
private
|
|
99
128
|
|
|
100
129
|
def normalize_enum(name, value, allowed, default:)
|
|
@@ -110,5 +139,11 @@ module LlmCostTracker
|
|
|
110
139
|
normalized[host.to_s.downcase] = provider.to_s
|
|
111
140
|
end
|
|
112
141
|
end
|
|
142
|
+
|
|
143
|
+
def ensure_shared_configuration_mutable!
|
|
144
|
+
return unless finalized?
|
|
145
|
+
|
|
146
|
+
raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
|
|
147
|
+
end
|
|
113
148
|
end
|
|
114
149
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddProviderResponseIdGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add llm_api_calls.provider_response_id"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_provider_response_id_to_llm_api_calls.rb.erb",
|
|
18
|
+
"db/migrate/add_provider_response_id_to_llm_api_calls.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddStreamingGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add llm_api_calls.stream and llm_api_calls.usage_source"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_streaming_to_llm_api_calls.rb.erb",
|
|
18
|
+
"db/migrate/add_streaming_to_llm_api_calls.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class AddProviderResponseIdToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
return if column_exists?(:llm_api_calls, :provider_response_id)
|
|
4
|
+
|
|
5
|
+
add_column :llm_api_calls, :provider_response_id, :string
|
|
6
|
+
add_index :llm_api_calls, :provider_response_id
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def down
|
|
10
|
+
return unless column_exists?(:llm_api_calls, :provider_response_id)
|
|
11
|
+
|
|
12
|
+
remove_index :llm_api_calls, :provider_response_id if index_exists?(:llm_api_calls, :provider_response_id)
|
|
13
|
+
remove_column :llm_api_calls, :provider_response_id
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
unless column_exists?(:llm_api_calls, :stream)
|
|
4
|
+
add_column :llm_api_calls, :stream, :boolean, null: false, default: false
|
|
5
|
+
add_index :llm_api_calls, :stream
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
unless column_exists?(:llm_api_calls, :usage_source)
|
|
9
|
+
add_column :llm_api_calls, :usage_source, :string
|
|
10
|
+
add_index :llm_api_calls, :usage_source
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def down
|
|
15
|
+
if column_exists?(:llm_api_calls, :usage_source)
|
|
16
|
+
remove_index :llm_api_calls, :usage_source if index_exists?(:llm_api_calls, :usage_source)
|
|
17
|
+
remove_column :llm_api_calls, :usage_source
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if column_exists?(:llm_api_calls, :stream)
|
|
21
|
+
remove_index :llm_api_calls, :stream if index_exists?(:llm_api_calls, :stream)
|
|
22
|
+
remove_column :llm_api_calls, :stream
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -10,6 +10,9 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
10
10
|
t.decimal :output_cost, precision: 20, scale: 8
|
|
11
11
|
t.decimal :total_cost, precision: 20, scale: 8
|
|
12
12
|
t.integer :latency_ms
|
|
13
|
+
t.boolean :stream, null: false, default: false
|
|
14
|
+
t.string :usage_source
|
|
15
|
+
t.string :provider_response_id
|
|
13
16
|
if postgresql?
|
|
14
17
|
t.jsonb :tags, null: false, default: {}
|
|
15
18
|
else
|
|
@@ -24,6 +27,9 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
24
27
|
add_index :llm_api_calls, :model
|
|
25
28
|
add_index :llm_api_calls, :tracked_at
|
|
26
29
|
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
30
|
+
add_index :llm_api_calls, :stream
|
|
31
|
+
add_index :llm_api_calls, :usage_source
|
|
32
|
+
add_index :llm_api_calls, :provider_response_id
|
|
27
33
|
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
28
34
|
end
|
|
29
35
|
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
#
|
|
15
15
|
# Optional metadata keys, ignored by cost calculation:
|
|
16
16
|
# - _source
|
|
17
|
+
# - _source_version
|
|
18
|
+
# - _fetched_at
|
|
17
19
|
# - _updated
|
|
18
20
|
# - _notes
|
|
21
|
+
# - _validator_override
|
|
19
22
|
#
|
|
20
23
|
# Example: custom fine-tune
|
|
21
24
|
# models:
|
|
@@ -30,7 +33,11 @@
|
|
|
30
33
|
# "gpt-4o":
|
|
31
34
|
# input: 2.00
|
|
32
35
|
# output: 8.00
|
|
33
|
-
# _source: "
|
|
36
|
+
# _source: "manual"
|
|
34
37
|
# _updated: "2026-04-18"
|
|
38
|
+
#
|
|
39
|
+
# Use _source: "manual" for custom or orphaned entries you never want sync to touch.
|
|
40
|
+
# Use _validator_override: ["skip_relative_change"] if a negotiated price would
|
|
41
|
+
# otherwise trip the >3x sync warning.
|
|
35
42
|
|
|
36
43
|
models:
|
|
@@ -16,11 +16,24 @@ module LlmCostTracker
|
|
|
16
16
|
|
|
17
17
|
self.table_name = "llm_api_calls"
|
|
18
18
|
|
|
19
|
-
# Scopes for querying
|
|
20
19
|
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
21
20
|
scope :without_cost, -> { where(total_cost: nil) }
|
|
22
21
|
scope :unknown_pricing, -> { without_cost }
|
|
23
22
|
scope :with_latency, -> { latency_column? ? where.not(latency_ms: nil) : none }
|
|
23
|
+
scope :streaming, -> { stream_column? ? where(stream: true) : none }
|
|
24
|
+
scope :non_streaming, -> { stream_column? ? where(stream: [false, nil]) : all }
|
|
25
|
+
scope :by_usage_source, ->(source) { usage_source_column? ? where(usage_source: source.to_s) : none }
|
|
26
|
+
scope :with_provider_response_id, lambda {
|
|
27
|
+
provider_response_id_column? ? where.not(provider_response_id: [nil, ""]) : none
|
|
28
|
+
}
|
|
29
|
+
scope :missing_provider_response_id, lambda {
|
|
30
|
+
provider_response_id_column? ? where(provider_response_id: [nil, ""]) : none
|
|
31
|
+
}
|
|
32
|
+
scope :streaming_missing_usage, lambda {
|
|
33
|
+
return none unless stream_column? && usage_source_column?
|
|
34
|
+
|
|
35
|
+
where(stream: true).where(usage_source: ["unknown", nil])
|
|
36
|
+
}
|
|
24
37
|
|
|
25
38
|
scope :with_json_tags, lambda {
|
|
26
39
|
if tags_json_column?
|
|
@@ -43,7 +56,6 @@ module LlmCostTracker
|
|
|
43
56
|
TagQuery.apply(self, tags)
|
|
44
57
|
end
|
|
45
58
|
|
|
46
|
-
# Aggregations
|
|
47
59
|
def self.total_cost
|
|
48
60
|
sum(:total_cost).to_f
|
|
49
61
|
end
|
|
@@ -18,22 +18,39 @@ module LlmCostTracker
|
|
|
18
18
|
|
|
19
19
|
request_url = request_env.url.to_s
|
|
20
20
|
request_body = read_body(request_env.body) || ""
|
|
21
|
+
parser = Parsers::Registry.find_for(request_url)
|
|
22
|
+
streaming = parser&.streaming_request?(request_url, request_body)
|
|
23
|
+
stream_buffer = install_stream_tap(request_env) if streaming
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
Tracker.enforce_budget! if parser
|
|
23
26
|
started_at = monotonic_time
|
|
24
27
|
|
|
25
28
|
@app.call(request_env).on_complete do |response_env|
|
|
26
|
-
process(
|
|
29
|
+
process(
|
|
30
|
+
parser: parser,
|
|
31
|
+
request_env: request_env,
|
|
32
|
+
request_url: request_url,
|
|
33
|
+
request_body: request_body,
|
|
34
|
+
response_env: response_env,
|
|
35
|
+
latency_ms: elapsed_ms(started_at),
|
|
36
|
+
streaming: streaming,
|
|
37
|
+
stream_buffer: stream_buffer
|
|
38
|
+
)
|
|
27
39
|
end
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
private
|
|
31
43
|
|
|
32
|
-
def process(request_env
|
|
33
|
-
|
|
44
|
+
def process(parser:, request_env:, request_url:, request_body:, response_env:,
|
|
45
|
+
latency_ms:, streaming:, stream_buffer:)
|
|
34
46
|
return unless parser
|
|
35
47
|
|
|
36
|
-
parsed =
|
|
48
|
+
parsed =
|
|
49
|
+
if streaming
|
|
50
|
+
parse_stream(parser, request_url, request_body, response_env, stream_buffer)
|
|
51
|
+
else
|
|
52
|
+
parse_response(parser, request_url, request_body, response_env)
|
|
53
|
+
end
|
|
37
54
|
return unless parsed
|
|
38
55
|
|
|
39
56
|
Tracker.record(
|
|
@@ -42,6 +59,9 @@ module LlmCostTracker
|
|
|
42
59
|
input_tokens: parsed.input_tokens,
|
|
43
60
|
output_tokens: parsed.output_tokens,
|
|
44
61
|
latency_ms: latency_ms,
|
|
62
|
+
stream: parsed.stream,
|
|
63
|
+
usage_source: parsed.usage_source,
|
|
64
|
+
provider_response_id: parsed.provider_response_id,
|
|
45
65
|
metadata: resolved_tags(request_env).merge(parsed.metadata)
|
|
46
66
|
)
|
|
47
67
|
rescue LlmCostTracker::Error
|
|
@@ -54,7 +74,9 @@ module LlmCostTracker
|
|
|
54
74
|
response_body = read_body(response_env.body)
|
|
55
75
|
unless response_body
|
|
56
76
|
Logging.warn(
|
|
57
|
-
"Unable to read response body for #{request_url};
|
|
77
|
+
"Unable to read response body for #{request_url}; " \
|
|
78
|
+
"streaming responses are captured automatically for OpenAI/Anthropic/Gemini " \
|
|
79
|
+
"or via LlmCostTracker.track_stream for custom clients."
|
|
58
80
|
)
|
|
59
81
|
return nil
|
|
60
82
|
end
|
|
@@ -62,10 +84,37 @@ module LlmCostTracker
|
|
|
62
84
|
parser.parse(request_url, request_body, response_env.status, response_body)
|
|
63
85
|
end
|
|
64
86
|
|
|
65
|
-
def
|
|
66
|
-
|
|
87
|
+
def parse_stream(parser, request_url, request_body, response_env, stream_buffer)
|
|
88
|
+
body = stream_buffer&.string
|
|
89
|
+
body = read_body(response_env.body) if body.nil? || body.empty?
|
|
90
|
+
|
|
91
|
+
if body.nil? || body.empty?
|
|
92
|
+
Logging.warn(
|
|
93
|
+
"Unable to capture streaming response for #{request_url}; " \
|
|
94
|
+
"fall back to LlmCostTracker.track_stream for manual capture."
|
|
95
|
+
)
|
|
96
|
+
return nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
events = Parsers::SSE.parse(body)
|
|
100
|
+
parser.parse_stream(request_url, request_body, response_env.status, events)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def install_stream_tap(request_env)
|
|
104
|
+
return nil unless request_env.respond_to?(:request) && request_env.request
|
|
67
105
|
|
|
68
|
-
|
|
106
|
+
original = request_env.request.on_data
|
|
107
|
+
return nil unless original
|
|
108
|
+
|
|
109
|
+
buffer = StringIO.new
|
|
110
|
+
request_env.request.on_data = proc do |chunk, size, env|
|
|
111
|
+
buffer << chunk.to_s
|
|
112
|
+
original.call(chunk, size, env)
|
|
113
|
+
end
|
|
114
|
+
buffer
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
Logging.warn("Unable to install streaming tap: #{e.class}: #{e.message}")
|
|
117
|
+
nil
|
|
69
118
|
end
|
|
70
119
|
|
|
71
120
|
def read_body(body)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ParameterHash
|
|
5
|
+
class << self
|
|
6
|
+
def hash_like?(value)
|
|
7
|
+
value.is_a?(Hash) || action_controller_parameters?(value)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_hash(value)
|
|
11
|
+
return {} if value.nil?
|
|
12
|
+
return value.to_unsafe_h if action_controller_parameters?(value)
|
|
13
|
+
return value.to_h if value.is_a?(Hash)
|
|
14
|
+
return {} unless value.respond_to?(:to_h)
|
|
15
|
+
|
|
16
|
+
hash = value.to_h
|
|
17
|
+
hash.is_a?(Hash) ? hash : {}
|
|
18
|
+
rescue ArgumentError, TypeError
|
|
19
|
+
{}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_indifferent_access(value)
|
|
23
|
+
to_hash(value).with_indifferent_access
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def action_controller_parameters?(value)
|
|
29
|
+
defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -10,11 +10,23 @@ module LlmCostTracker
|
|
|
10
10
|
:cached_input_tokens,
|
|
11
11
|
:cache_read_input_tokens,
|
|
12
12
|
:cache_creation_input_tokens,
|
|
13
|
-
:reasoning_tokens
|
|
13
|
+
:reasoning_tokens,
|
|
14
|
+
:stream,
|
|
15
|
+
:usage_source,
|
|
16
|
+
:provider_response_id
|
|
14
17
|
)
|
|
15
18
|
|
|
16
19
|
class ParsedUsage
|
|
17
|
-
TRACKING_KEYS = %i[
|
|
20
|
+
TRACKING_KEYS = %i[
|
|
21
|
+
provider
|
|
22
|
+
model
|
|
23
|
+
input_tokens
|
|
24
|
+
output_tokens
|
|
25
|
+
total_tokens
|
|
26
|
+
stream
|
|
27
|
+
usage_source
|
|
28
|
+
provider_response_id
|
|
29
|
+
].freeze
|
|
18
30
|
|
|
19
31
|
def self.build(**attributes)
|
|
20
32
|
new(
|
|
@@ -26,7 +38,10 @@ module LlmCostTracker
|
|
|
26
38
|
cached_input_tokens: attributes[:cached_input_tokens],
|
|
27
39
|
cache_read_input_tokens: attributes[:cache_read_input_tokens],
|
|
28
40
|
cache_creation_input_tokens: attributes[:cache_creation_input_tokens],
|
|
29
|
-
reasoning_tokens: attributes[:reasoning_tokens]
|
|
41
|
+
reasoning_tokens: attributes[:reasoning_tokens],
|
|
42
|
+
stream: attributes[:stream] || false,
|
|
43
|
+
usage_source: attributes[:usage_source],
|
|
44
|
+
provider_response_id: attributes[:provider_response_id]
|
|
30
45
|
)
|
|
31
46
|
end
|
|
32
47
|
|
|
@@ -16,6 +16,10 @@ module LlmCostTracker
|
|
|
16
16
|
false
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def provider_names
|
|
20
|
+
%w[anthropic]
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def parse(_request_url, request_body, response_status, response_body)
|
|
20
24
|
return nil unless response_status == 200
|
|
21
25
|
|
|
@@ -27,13 +31,106 @@ module LlmCostTracker
|
|
|
27
31
|
|
|
28
32
|
ParsedUsage.build(
|
|
29
33
|
provider: "anthropic",
|
|
34
|
+
provider_response_id: response["id"],
|
|
30
35
|
model: response["model"] || request["model"],
|
|
31
36
|
input_tokens: usage["input_tokens"].to_i,
|
|
32
37
|
output_tokens: usage["output_tokens"].to_i,
|
|
33
38
|
total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i +
|
|
34
39
|
usage["cache_read_input_tokens"].to_i + usage["cache_creation_input_tokens"].to_i,
|
|
35
40
|
cache_read_input_tokens: usage["cache_read_input_tokens"],
|
|
36
|
-
cache_creation_input_tokens: usage["cache_creation_input_tokens"]
|
|
41
|
+
cache_creation_input_tokens: usage["cache_creation_input_tokens"],
|
|
42
|
+
usage_source: :response
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_stream(_request_url, request_body, response_status, events)
|
|
47
|
+
return nil unless response_status == 200
|
|
48
|
+
|
|
49
|
+
request = safe_json_parse(request_body)
|
|
50
|
+
model = stream_model(events) || request["model"]
|
|
51
|
+
usage = stream_usage(events)
|
|
52
|
+
response_id = stream_response_id(events)
|
|
53
|
+
|
|
54
|
+
usage ? build_stream_result(model, usage, response_id) : build_unknown_stream_result(model, response_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def stream_usage(events)
|
|
60
|
+
start_usage = nil
|
|
61
|
+
latest_delta = nil
|
|
62
|
+
|
|
63
|
+
events.each do |event|
|
|
64
|
+
data = event[:data]
|
|
65
|
+
next unless data.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
case data["type"]
|
|
68
|
+
when "message_start"
|
|
69
|
+
start_usage = data.dig("message", "usage")
|
|
70
|
+
when "message_delta"
|
|
71
|
+
latest_delta = data["usage"] if data["usage"].is_a?(Hash)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return nil unless start_usage || latest_delta
|
|
76
|
+
|
|
77
|
+
(start_usage || {}).merge(latest_delta || {}) do |_key, start_val, delta_val|
|
|
78
|
+
delta_val.nil? ? start_val : delta_val
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stream_model(events)
|
|
83
|
+
events.each do |event|
|
|
84
|
+
data = event[:data]
|
|
85
|
+
next unless data.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
model = data.dig("message", "model")
|
|
88
|
+
return model if model && !model.empty?
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stream_response_id(events)
|
|
94
|
+
events.each do |event|
|
|
95
|
+
data = event[:data]
|
|
96
|
+
next unless data.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
id = data.dig("message", "id") || data["id"]
|
|
99
|
+
return id if id && !id.to_s.empty?
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_stream_result(model, usage, response_id)
|
|
105
|
+
input = usage["input_tokens"].to_i
|
|
106
|
+
output = usage["output_tokens"].to_i
|
|
107
|
+
cache_read = usage["cache_read_input_tokens"].to_i
|
|
108
|
+
cache_creation = usage["cache_creation_input_tokens"].to_i
|
|
109
|
+
|
|
110
|
+
ParsedUsage.build(
|
|
111
|
+
provider: "anthropic",
|
|
112
|
+
provider_response_id: response_id,
|
|
113
|
+
model: model,
|
|
114
|
+
input_tokens: input,
|
|
115
|
+
output_tokens: output,
|
|
116
|
+
total_tokens: input + output + cache_read + cache_creation,
|
|
117
|
+
cache_read_input_tokens: usage["cache_read_input_tokens"],
|
|
118
|
+
cache_creation_input_tokens: usage["cache_creation_input_tokens"],
|
|
119
|
+
stream: true,
|
|
120
|
+
usage_source: :stream_final
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_unknown_stream_result(model, response_id)
|
|
125
|
+
ParsedUsage.build(
|
|
126
|
+
provider: "anthropic",
|
|
127
|
+
provider_response_id: response_id,
|
|
128
|
+
model: model,
|
|
129
|
+
input_tokens: 0,
|
|
130
|
+
output_tokens: 0,
|
|
131
|
+
total_tokens: 0,
|
|
132
|
+
stream: true,
|
|
133
|
+
usage_source: :unknown
|
|
37
134
|
)
|
|
38
135
|
end
|
|
39
136
|
end
|