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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +124 -68
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +1 -4
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  10. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +26 -24
  17. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
  19. data/app/services/llm_cost_tracker/pagination.rb +1 -9
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +23 -13
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +11 -1
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +78 -10
  25. data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
  26. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
  27. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
  28. data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
  29. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  30. data/lib/llm_cost_tracker/assets.rb +6 -11
  31. data/lib/llm_cost_tracker/configuration.rb +78 -43
  32. data/lib/llm_cost_tracker/event.rb +3 -0
  33. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +6 -0
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  40. data/lib/llm_cost_tracker/llm_api_call.rb +14 -2
  41. data/lib/llm_cost_tracker/middleware/faraday.rb +58 -9
  42. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  43. data/lib/llm_cost_tracker/parsed_usage.rb +18 -3
  44. data/lib/llm_cost_tracker/parsers/anthropic.rb +98 -1
  45. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  46. data/lib/llm_cost_tracker/parsers/gemini.rb +83 -6
  47. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  48. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -5
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +69 -1
  50. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  51. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +23 -8
  53. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  54. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  55. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  56. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  57. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  58. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  59. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  60. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  61. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  62. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  63. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  64. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  65. data/lib/llm_cost_tracker/price_sync.rb +142 -0
  66. data/lib/llm_cost_tracker/pricing.rb +0 -11
  67. data/lib/llm_cost_tracker/railtie.rb +0 -1
  68. data/lib/llm_cost_tracker/report.rb +0 -5
  69. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -9
  70. data/lib/llm_cost_tracker/stream_collector.rb +162 -0
  71. data/lib/llm_cost_tracker/tags_column.rb +12 -0
  72. data/lib/llm_cost_tracker/tracker.rb +23 -12
  73. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. data/lib/llm_cost_tracker.rb +48 -35
  76. data/lib/tasks/llm_cost_tracker.rake +116 -0
  77. data/llm_cost_tracker.gemspec +8 -6
  78. 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
- attr_accessor :enabled,
19
- :custom_storage, # callable object for :custom backend
20
- :default_tags, # Hash of default tags added to every event
21
- :on_budget_exceeded, # callable, receives event hash
22
- :monthly_budget, # Float, in USD — nil means no limit
23
- :log_level, # :debug, :info, :warn
24
- :prices_file, # JSON/YAML file that overrides built-in prices
25
- :pricing_overrides, # Hash to override built-in pricing
26
- :report_tag_breakdowns # Array of tag keys to break down in the rake report
27
-
28
- attr_reader :budget_exceeded_behavior, # :notify, :raise, :block_requests
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 storage_backend=(value)
56
- @storage_backend = normalize_enum(:storage_backend, value, STORAGE_BACKENDS, default: :log)
72
+ def pricing_overrides=(value)
73
+ ensure_shared_configuration_mutable!
74
+ @pricing_overrides = value
57
75
  end
58
76
 
59
- def budget_exceeded_behavior=(value)
60
- @budget_exceeded_behavior = normalize_enum(
61
- :budget_exceeded_behavior,
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
- def storage_error_behavior=(value)
69
- @storage_error_behavior = normalize_enum(
70
- :storage_error_behavior,
71
- value,
72
- STORAGE_ERROR_BEHAVIORS,
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
- def unknown_pricing_behavior=(value)
78
- @unknown_pricing_behavior = normalize_enum(
79
- :unknown_pricing_behavior,
80
- value,
81
- UNKNOWN_PRICING_BEHAVIORS,
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 active_record?
91
- storage_backend == :active_record
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 log?
95
- storage_backend == :log
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
@@ -10,6 +10,9 @@ module LlmCostTracker
10
10
  :cost,
11
11
  :tags,
12
12
  :latency_ms,
13
+ :stream,
14
+ :usage_source,
15
+ :provider_response_id,
13
16
  :tracked_at
14
17
  ) do
15
18
  def to_h
@@ -10,6 +10,7 @@ module LlmCostTracker
10
10
  cached_input_tokens
11
11
  input_tokens
12
12
  output_tokens
13
+ provider_response_id
13
14
  reasoning_tokens
14
15
  total_tokens
15
16
  ].freeze
@@ -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
 
@@ -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: "Enterprise agreement"
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
- enforce_budget_before_request(request_url)
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(request_env, request_url, request_body, response_env, elapsed_ms(started_at))
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, request_url, request_body, response_env, latency_ms)
33
- parser = Parsers::Registry.find_for(request_url)
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 = parse_response(parser, request_url, request_body, response_env)
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}; streaming/SSE responses require manual tracking."
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 enforce_budget_before_request(request_url)
66
- return unless Parsers::Registry.find_for(request_url)
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
- Tracker.enforce_budget!
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[provider model input_tokens output_tokens total_tokens].freeze
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