llm_cost_tracker 0.14.1 → 0.14.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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  7. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
  9. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  10. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  11. data/app/models/llm_cost_tracker/call.rb +10 -14
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  16. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  17. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  19. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  21. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  22. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  23. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  26. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  27. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  28. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
  29. data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
  30. data/lib/llm_cost_tracker/budget.rb +44 -55
  31. data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
  32. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  33. data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
  35. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  36. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  37. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  38. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  39. data/lib/llm_cost_tracker/configuration.rb +5 -9
  40. data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
  41. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  42. data/lib/llm_cost_tracker/doctor.rb +19 -2
  43. data/lib/llm_cost_tracker/engine.rb +0 -1
  44. data/lib/llm_cost_tracker/event.rb +8 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  62. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  63. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  64. data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
  65. data/lib/llm_cost_tracker/integrations/base.rb +39 -18
  66. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  67. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  68. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  69. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
  70. data/lib/llm_cost_tracker/integrations.rb +1 -20
  71. data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
  72. data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
  73. data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
  74. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  75. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  76. data/lib/llm_cost_tracker/ledger/store.rb +2 -2
  77. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  78. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  79. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  80. data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
  81. data/lib/llm_cost_tracker/parsers.rb +7 -40
  82. data/lib/llm_cost_tracker/prices.json +2237 -154
  83. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  84. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  85. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  86. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  87. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  88. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  89. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  90. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  91. data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
  92. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  93. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  94. data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
  95. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  96. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  97. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  98. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
  99. data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
  100. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  101. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  102. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  103. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
  104. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  105. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
  106. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
  107. data/lib/llm_cost_tracker/report/data.rb +1 -1
  108. data/lib/llm_cost_tracker/retention.rb +1 -3
  109. data/lib/llm_cost_tracker/tracker.rb +17 -9
  110. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  111. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  112. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  113. data/lib/llm_cost_tracker/version.rb +1 -1
  114. data/lib/tasks/llm_cost_tracker.rake +24 -21
  115. data/llm_cost_tracker.gemspec +63 -0
  116. metadata +8 -7
@@ -21,10 +21,11 @@ module LlmCostTracker
21
21
  end
22
22
 
23
23
  def current_schema_errors
24
- connection = model.connection
25
- Adapter.ensure_supported!(connection)
26
- table_name = model.table_name
27
- return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
24
+ Adapter.ensure_supported!(model.connection)
25
+ unless model.table_exists?
26
+ model.connection.schema_cache.clear_data_source_cache!(model.table_name)
27
+ return ["#{model.table_name} table is missing"] unless model.table_exists?
28
+ end
28
29
 
29
30
  columns_hash = model.columns_hash
30
31
  cache = @schema_capabilities
@@ -118,10 +118,10 @@ module LlmCostTracker
118
118
 
119
119
  def insert_call_tags(events, call_ids)
120
120
  rows = events.flat_map do |event|
121
- (event.tags || {}).map do |key, value|
121
+ (event.tags || {}).to_h { |key, value| [key.to_s, value] }.map do |key, value|
122
122
  {
123
123
  llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
124
- key: key.to_s,
124
+ key: key,
125
125
  value: Tags::Encoding.encode(Storable.clean(value))
126
126
  }.merge(budget_columns_for(event))
127
127
  end
@@ -19,7 +19,7 @@ module LlmCostTracker
19
19
  end
20
20
 
21
21
  def value_arel
22
- Arel.sql("#{call_tag_table}.#{quote_column('value')}")
22
+ Arel.sql(raw_value_sql(LlmCostTracker::CallTag.connection))
23
23
  end
24
24
 
25
25
  def label_sql(connection)
@@ -35,10 +35,6 @@ module LlmCostTracker
35
35
  def call_tag_table
36
36
  LlmCostTracker::CallTag.quoted_table_name
37
37
  end
38
-
39
- def quote_column(name)
40
- LlmCostTracker::CallTag.connection.quote_column_name(name)
41
- end
42
38
  end
43
39
  end
44
40
  end
@@ -10,8 +10,7 @@ module LlmCostTracker
10
10
 
11
11
  def self.encode(value)
12
12
  encoded = case value
13
- when Hash then JSON.generate(normalize_hash(value))
14
- when Array then JSON.generate(normalize_array(value))
13
+ when Hash, Array then JSON.generate(normalize_value(value))
15
14
  else value.to_s
16
15
  end
17
16
  truncate(encoded)
@@ -24,18 +23,10 @@ module LlmCostTracker
24
23
  string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
25
24
  end
26
25
 
27
- def self.normalize_hash(hash)
28
- hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
29
- end
30
-
31
- def self.normalize_array(array)
32
- array.map { |v| normalize_value(v) }
33
- end
34
-
35
26
  def self.normalize_value(value)
36
27
  case value
37
- when Hash then normalize_hash(value)
38
- when Array then normalize_array(value)
28
+ when Hash then value.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
29
+ when Array then value.map { |v| normalize_value(v) }
39
30
  else value.to_s
40
31
  end
41
32
  end
@@ -9,8 +9,6 @@ module LlmCostTracker
9
9
  class << self
10
10
  def apply(tags)
11
11
  normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
12
- return LlmCostTracker::Call.all if normalized_tags.empty?
13
-
14
12
  normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
15
13
  relation.where(id: LlmCostTracker::CallTag.where(key: key,
16
14
  value: value).select(:llm_cost_tracker_call_id))
@@ -11,6 +11,8 @@ require_relative "../timing"
11
11
  module LlmCostTracker
12
12
  module Middleware
13
13
  class Faraday < ::Faraday::Middleware
14
+ MULTIPART_MODEL = /name="model"\r\n(?:[^\r\n]+\r\n)*\r\n([^\r\n]+)\r\n/
15
+
14
16
  def initialize(app, **options)
15
17
  super(app)
16
18
  @tags = options.fetch(:tags, {})
@@ -20,8 +22,8 @@ module LlmCostTracker
20
22
  return @app.call(request_env) unless LlmCostTracker.configuration.enabled
21
23
 
22
24
  request_url = request_env.url.to_s
23
- request_body = read_body(request_env.body)
24
25
  parser = Parsers.find_for(request_url)
26
+ request_body = read_body(request_env.body) || multipart_model_body(request_env, parser)
25
27
  request_parsed = parser&.safe_json_parse(request_body)
26
28
  streaming = parser&.streaming_request?(request_url, request_parsed)
27
29
  if streaming
@@ -29,8 +31,8 @@ module LlmCostTracker
29
31
  end
30
32
  stream_buffer = install_stream_tap(request_env, parser) if streaming
31
33
 
32
- if parser
33
- context_tags, metadata = tag_snapshot(request_env)
34
+ context_tags, metadata = tag_snapshot(request_env) if parser
35
+ if parser && request_env.method == :post
34
36
  Budget.enforce!(
35
37
  provider: parser.provider_for(request_url),
36
38
  model: parser.model_for(request_url, request_parsed),
@@ -72,7 +74,7 @@ module LlmCostTracker
72
74
  request_url: request_url,
73
75
  request_body: request_body,
74
76
  response_env: response_env,
75
- latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
77
+ latency_ms: (LlmCostTracker::Timing.elapsed_ms(started_at) if request_env.method == :post),
76
78
  streaming: streaming,
77
79
  stream_buffer: stream_buffer,
78
80
  context_tags: context_tags,
@@ -165,16 +167,12 @@ module LlmCostTracker
165
167
  response_env: response_env
166
168
  )
167
169
  end
168
- return unless parsed
169
170
 
170
- Tracker.record(
171
- event: parsed,
172
- latency_ms: latency_ms,
173
- metadata: metadata,
174
- context_tags: context_tags
175
- )
171
+ Tracker.record(event: parsed, latency_ms: latency_ms, metadata: metadata, context_tags: context_tags) if parsed
176
172
  rescue *LlmCostTracker::CALLER_ERRORS
177
173
  raise
174
+ rescue ActiveRecord::RecordNotUnique
175
+ nil
178
176
  rescue StandardError => e
179
177
  Logging.warn("Error processing response: #{e.class}: #{e.message}")
180
178
  end
@@ -261,16 +259,35 @@ module LlmCostTracker
261
259
  end
262
260
  end
263
261
 
262
+ def multipart_model_body(request_env, parser)
263
+ body = request_env.body
264
+ multipart = request_env.request_headers["Content-Type"].to_s.start_with?("multipart/form-data")
265
+ return nil unless parser && multipart && body.respond_to?(:read) && body.respond_to?(:rewind)
266
+
267
+ model = multipart_model(body)
268
+ model && { "model" => model }.to_json
269
+ rescue StandardError => e
270
+ Logging.warn("Unable to read the model from a multipart request: #{e.class}: #{e.message}")
271
+ nil
272
+ end
273
+
274
+ def multipart_model(body)
275
+ window = String.new(encoding: Encoding::BINARY)
276
+ while (chunk = body.read(65_536))
277
+ window << chunk.b
278
+ model = window[MULTIPART_MODEL, 1]
279
+ return model.force_encoding(Encoding::UTF_8) if model
280
+
281
+ window = window.byteslice([window.bytesize - 512, 0].max..)
282
+ end
283
+ ensure
284
+ body.rewind
285
+ end
286
+
264
287
  def resolved_tags(request_env)
265
- tags =
266
- if @tags.respond_to?(:call)
267
- @tags.arity.zero? ? @tags.call : @tags.call(request_env)
268
- else
269
- @tags
270
- end
271
- return {} if tags.nil?
288
+ return @tags.to_h unless @tags.respond_to?(:call)
272
289
 
273
- tags.to_h
290
+ (@tags.arity.zero? ? @tags.call : @tags.call(request_env)).to_h
274
291
  end
275
292
 
276
293
  def tag_snapshot(request_env)
@@ -8,50 +8,27 @@ require_relative "providers"
8
8
 
9
9
  module LlmCostTracker
10
10
  module Parsers
11
- PARSER_PROVIDERS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
11
+ PARSER_PROVIDERS = %i[Openai Azure Anthropic Gemini OpenaiCompatible].freeze
12
12
 
13
13
  def self.find_for(url)
14
- instances.each do |klass, instance|
15
- return instance if klass.match?(url)
16
- end
17
- nil
14
+ instances.find { |parser| parser.class.match?(url) }
18
15
  end
19
16
 
20
17
  def self.find_for_provider(provider)
21
- provider_name = provider.to_s.downcase
22
- instances.each do |klass, instance|
23
- return instance if klass.provider_names.include?(provider_name)
24
- end
25
- nil
18
+ all_for_provider(provider).first
26
19
  end
27
20
 
28
- def self.parser_classes
29
- PARSER_PROVIDERS.map { |name| Providers.const_get(name)::Parser }
21
+ def self.all_for_provider(provider)
22
+ provider_name = provider.to_s.downcase
23
+ instances.select { |parser| parser.class.provider_names.include?(provider_name) }
30
24
  end
31
- private_class_method :parser_classes
32
25
 
33
26
  def self.instances
34
- @instances ||= parser_classes.to_h { |klass| [klass, klass.new] }.freeze
27
+ @instances ||= PARSER_PROVIDERS.map { |name| Providers.const_get(name)::Parser.new }.freeze
35
28
  end
36
29
  private_class_method :instances
37
30
 
38
31
  module UrlMatchers
39
- def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
40
- uri_matches?(url) do |uri|
41
- host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
42
- path_match = path_matches?(
43
- uri,
44
- exact_paths: exact_paths,
45
- path_includes: path_includes,
46
- path_suffixes: path_suffixes,
47
- path_pattern: path_pattern
48
- )
49
- extra_match = block_given? ? yield(uri) : true
50
-
51
- !!(host_match && path_match && extra_match)
52
- end
53
- end
54
-
55
32
  def uri_matches?(url)
56
33
  uri = parsed_uri(url)
57
34
  uri ? yield(uri) : false
@@ -62,16 +39,6 @@ module LlmCostTracker
62
39
  rescue URI::InvalidURIError
63
40
  nil
64
41
  end
65
-
66
- def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
67
- path = uri.path.to_s
68
- matches = true
69
- matches &&= exact_paths.include?(path) if exact_paths
70
- matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
71
- matches &&= path.match?(path_pattern) if path_pattern
72
- matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
73
- matches
74
- end
75
42
  end
76
43
 
77
44
  class Base