llm_cost_tracker 0.9.0 → 0.10.0

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/README.md +2 -1
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
  6. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  7. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  8. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  9. data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
  10. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  11. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  12. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  13. data/lib/llm_cost_tracker/budget.rb +28 -6
  14. data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
  15. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
  16. data/lib/llm_cost_tracker/configuration.rb +31 -28
  17. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  18. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  19. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  20. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  21. data/lib/llm_cost_tracker/doctor.rb +6 -17
  22. data/lib/llm_cost_tracker/engine.rb +1 -2
  23. data/lib/llm_cost_tracker/errors.rb +3 -2
  24. data/lib/llm_cost_tracker/event.rb +47 -0
  25. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  26. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  27. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  28. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  29. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  37. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  38. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
  39. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  40. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  41. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
  43. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  44. data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
  45. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
  46. data/lib/llm_cost_tracker/integrations.rb +14 -13
  47. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  48. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  49. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  50. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  51. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  52. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  53. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  54. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  55. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  56. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  57. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  58. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  59. data/lib/llm_cost_tracker/logging.rb +0 -4
  60. data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
  61. data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
  62. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  63. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  64. data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
  65. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  66. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
  67. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
  68. data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
  69. data/lib/llm_cost_tracker/parsers.rb +31 -4
  70. data/lib/llm_cost_tracker/prices.json +567 -579
  71. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  72. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  73. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  74. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  75. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  76. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  77. data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
  78. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  79. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  80. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  81. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  82. data/lib/llm_cost_tracker/pricing.rb +72 -27
  83. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  84. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  85. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  86. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  87. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  88. data/lib/llm_cost_tracker/railtie.rb +3 -1
  89. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  90. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  91. data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
  92. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
  93. data/lib/llm_cost_tracker/report.rb +0 -4
  94. data/lib/llm_cost_tracker/retention.rb +20 -8
  95. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  96. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  97. data/lib/llm_cost_tracker/tracker.rb +33 -74
  98. data/lib/llm_cost_tracker/version.rb +1 -1
  99. data/lib/llm_cost_tracker.rb +11 -15
  100. data/lib/tasks/llm_cost_tracker.rake +16 -2
  101. metadata +18 -7
  102. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  103. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  104. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../billing/line_item"
4
+ require_relative "../providers/openai/model_families"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Parsers
@@ -12,13 +13,6 @@ module LlmCostTracker
12
13
  "mcp_call" => :mcp_call
13
14
  }.freeze
14
15
 
15
- REASONING_MODEL_PATTERNS = [
16
- /\Agpt-5(\b|[\d.-])/i,
17
- /\Ao\d+(\b|[\d.-])/i
18
- ].freeze
19
- NON_REASONING_GPT5_PATTERN = /\Agpt-5(?:\.\d+)?-chat\b/i
20
- private_constant :NON_REASONING_GPT5_PATTERN
21
-
22
16
  module_function
23
17
 
24
18
  def line_items_from_output(output_items, request: nil, model: nil)
@@ -29,6 +23,40 @@ module LlmCostTracker
29
23
  .filter_map { |item| build_line_item(item, request: request, model: model) }
30
24
  end
31
25
 
26
+ def service_line_items_for(response, request: nil, model: nil)
27
+ output_items = Array(response["output"])
28
+ output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
29
+ line_items_from_output(output_items, request: request, model: model)
30
+ end
31
+
32
+ CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
33
+ CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
34
+
35
+ def chat_completions_web_search_items(response, model: nil)
36
+ return [] unless response["choices"]
37
+
38
+ provider_field = chat_completions_search_provider_field(response["choices"], model)
39
+ return [] unless provider_field
40
+
41
+ [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
42
+ "provider_field" => provider_field }]
43
+ end
44
+
45
+ def chat_completions_search_provider_field(choices, model)
46
+ return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
47
+ return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
48
+
49
+ nil
50
+ end
51
+
52
+ def chat_completions_used_web_search?(choices)
53
+ Array(choices).any? do |choice|
54
+ Array(choice.dig("message", "annotations")).any? do |annotation|
55
+ annotation.is_a?(Hash) && annotation["type"] == "url_citation"
56
+ end
57
+ end
58
+ end
59
+
32
60
  def billable?(item)
33
61
  return false unless item.is_a?(Hash)
34
62
 
@@ -68,7 +96,7 @@ module LlmCostTracker
68
96
  quantity: 1,
69
97
  cost_status: Billing::CostStatus::UNKNOWN,
70
98
  pricing_basis: :provider_usage,
71
- provider_field: "response.output.#{item['type']}",
99
+ provider_field: item["provider_field"] || "response.output.#{item['type']}",
72
100
  provider_item_id: provider_item_id,
73
101
  details: line_item_details(item)
74
102
  )
@@ -77,7 +105,7 @@ module LlmCostTracker
77
105
  def component_key_for(item, request:, model:)
78
106
  component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
79
107
  return component unless component == :web_search_request
80
- return component unless web_search_preview_used?(request)
108
+ return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
81
109
 
82
110
  reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
83
111
  end
@@ -92,13 +120,18 @@ module LlmCostTracker
92
120
  end
93
121
  end
94
122
 
95
- def reasoning_model?(model)
123
+ def chat_completions_search_model?(model)
96
124
  return false unless model
97
125
 
98
126
  name = model.to_s.split("/", 2).last
99
- return false if NON_REASONING_GPT5_PATTERN.match?(name)
127
+ LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(name)
128
+ end
100
129
 
101
- REASONING_MODEL_PATTERNS.any? { |pattern| pattern.match?(name) }
130
+ def reasoning_model?(model)
131
+ return false unless model
132
+
133
+ name = model.to_s.split("/", 2).last
134
+ LlmCostTracker::Providers::Openai::ModelFamilies.reasoning?(name)
102
135
  end
103
136
 
104
137
  def line_item_details(item)
@@ -109,10 +142,6 @@ module LlmCostTracker
109
142
  }.compact
110
143
  end
111
144
 
112
- def openai_service_line_items(response, request: nil)
113
- line_items_from_output(response["output"], request: request, model: response["model"])
114
- end
115
-
116
145
  def openai_stream_service_line_items(events, request: nil, model: nil)
117
146
  output_items = []
118
147
  each_event_data(events) do |data|
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "openai_service_charges"
4
+ require_relative "../providers/openai/hosts"
5
+ require_relative "../providers/openai/model_families"
4
6
 
5
7
  module LlmCostTracker
6
8
  module Parsers
7
9
  module OpenaiUsage
8
10
  include OpenaiServiceCharges
9
11
 
10
- OPENAI_DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
11
-
12
12
  class << self
13
13
  def combined_pricing_mode(host:, model:, service_tier:)
14
14
  modes = [Pricing.normalize_mode(service_tier)]
@@ -18,19 +18,12 @@ module LlmCostTracker
18
18
  end
19
19
 
20
20
  def regional_processing?(host:, model:)
21
- host.to_s.downcase.match?(OPENAI_DATA_RESIDENCY_HOST_PATTERN) && data_residency_model?(model)
22
- end
23
-
24
- def data_residency_model?(model)
25
- model.to_s.match?(
26
- /\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro|codex(?:-mini|-max)?))?(?:-\d{4}-\d{2}-\d{2})?\z/
27
- )
21
+ LlmCostTracker::Providers::Openai::Hosts.data_residency?(host) &&
22
+ LlmCostTracker::Providers::Openai::ModelFamilies.data_residency?(model)
28
23
  end
29
24
  end
30
25
 
31
- private
32
-
33
- def parse_openai_usage(request_url:, request_body:, response_status:, response_body:)
26
+ def parse(request_url:, request_body:, response_status:, response_body:, **)
34
27
  return nil unless response_status == 200
35
28
 
36
29
  response = safe_json_parse(response_body)
@@ -42,7 +35,7 @@ module LlmCostTracker
42
35
 
43
36
  model = response["model"] || request["model"]
44
37
 
45
- UsageCapture.build(
38
+ Event.build(
46
39
  provider: provider_for(request_url),
47
40
  provider_response_id: response["id"],
48
41
  pricing_mode: pricing_mode(
@@ -53,11 +46,11 @@ module LlmCostTracker
53
46
  model: model,
54
47
  token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
55
48
  usage_source: :response,
56
- service_line_items: openai_service_line_items(response, request: request)
49
+ service_line_items: service_line_items_for(response, request: request, model: response["model"])
57
50
  )
58
51
  end
59
52
 
60
- def parse_openai_stream_usage(response_status:, request_url: nil, request_body: nil, events: [])
53
+ def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
61
54
  return nil unless response_status == 200
62
55
 
63
56
  request = safe_json_parse(request_body)
@@ -70,6 +63,12 @@ module LlmCostTracker
70
63
  build_unknown_stream_usage(**context)
71
64
  end
72
65
 
66
+ def auto_enable_stream_usage?(request_url)
67
+ openai_chat_completions_url?(request_url)
68
+ end
69
+
70
+ private
71
+
73
72
  def stream_capture_context(events:, request:, request_url:)
74
73
  model = find_event_value(events) do |data|
75
74
  data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
@@ -91,7 +90,7 @@ module LlmCostTracker
91
90
 
92
91
  def build_known_stream_usage(usage:, provider:, model:, provider_response_id:, pricing_mode:, service_line_items:)
93
92
  cache_read = cache_read_input_tokens(usage)
94
- UsageCapture.build(
93
+ Event.build(
95
94
  provider: provider,
96
95
  provider_response_id: provider_response_id,
97
96
  pricing_mode: pricing_mode,
@@ -137,9 +136,6 @@ module LlmCostTracker
137
136
  OpenaiUsage.combined_pricing_mode(host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier)
138
137
  end
139
138
 
140
- IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
141
- private_constant :IMAGE_OUTPUT_MODEL_PATTERN
142
-
143
139
  def token_usage(usage:, cache_read:, model: nil)
144
140
  audio_input = audio_input_tokens(usage)
145
141
  audio_output = audio_output_tokens(usage)
@@ -150,7 +146,7 @@ module LlmCostTracker
150
146
  image_output, regular_output_remainder = split_stream_image_output(
151
147
  raw_output: raw_output, image_output_details: image_output_details,
152
148
  text_output_details: text_output_details, audio_output: audio_output,
153
- default_to_image: model.to_s.match?(IMAGE_OUTPUT_MODEL_PATTERN)
149
+ default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
154
150
  )
155
151
 
156
152
  TokenUsage.build(
@@ -2,19 +2,46 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Parsers
5
- BUILT_INS = [Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new].freeze
5
+ autoload :Base, "llm_cost_tracker/parsers/base"
6
+ autoload :OpenaiUsage, "llm_cost_tracker/parsers/openai_usage"
7
+ autoload :OpenaiServiceCharges, "llm_cost_tracker/parsers/openai_service_charges"
8
+ autoload :SSE, "llm_cost_tracker/parsers/sse"
9
+ autoload :Openai, "llm_cost_tracker/parsers/openai"
10
+ autoload :Azure, "llm_cost_tracker/parsers/azure"
11
+ autoload :OpenaiCompatible, "llm_cost_tracker/parsers/openai_compatible"
12
+ autoload :Anthropic, "llm_cost_tracker/parsers/anthropic"
13
+ autoload :Gemini, "llm_cost_tracker/parsers/gemini"
14
+
15
+ MUTEX = Mutex.new
16
+ PARSER_CONSTANTS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
6
17
 
7
18
  module_function
8
19
 
9
20
  def find_for(url)
10
- BUILT_INS.find { |parser| parser.match?(url) }
21
+ PARSER_CONSTANTS.each do |name|
22
+ klass = const_get(name)
23
+ return instance_for(klass) if klass.match?(url)
24
+ end
25
+ nil
11
26
  end
12
27
 
13
28
  def find_for_provider(provider)
14
29
  provider_name = provider.to_s.downcase
15
- BUILT_INS.find do |parser|
16
- parser.provider_names.include?(provider_name)
30
+ PARSER_CONSTANTS.each do |name|
31
+ klass = const_get(name)
32
+ return instance_for(klass) if klass.provider_names.include?(provider_name)
33
+ end
34
+ nil
35
+ end
36
+
37
+ def instance_for(klass)
38
+ cached = (@instances ||= {})[klass]
39
+ return cached if cached
40
+
41
+ MUTEX.synchronize do
42
+ @instances[klass] ||= klass.new
17
43
  end
18
44
  end
45
+ private_class_method :instance_for
19
46
  end
20
47
  end