llm_cost_tracker 0.8.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. 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
@@ -8,15 +9,52 @@ module LlmCostTracker
8
9
  RESPONSE_OUTPUT_COMPONENTS = {
9
10
  "web_search_call" => :web_search_request,
10
11
  "file_search_call" => :file_search_call,
11
- "code_interpreter_call" => :container_session
12
+ "code_interpreter_call" => :container_session,
13
+ "mcp_call" => :mcp_call
12
14
  }.freeze
13
15
 
14
16
  module_function
15
17
 
16
- def line_items_from_output(output_items)
18
+ def line_items_from_output(output_items, request: nil, model: nil)
17
19
  deduped = {}
18
20
  Array(output_items).each { |item| store_output_item(deduped, item) }
19
- deduped.values.filter_map { |item| build_line_item(item) }
21
+ deduped.values
22
+ .select { |item| billable?(item) }
23
+ .filter_map { |item| build_line_item(item, request: request, model: model) }
24
+ end
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
20
58
  end
21
59
 
22
60
  def billable?(item)
@@ -31,7 +69,7 @@ module LlmCostTracker
31
69
  end
32
70
 
33
71
  def store_output_item(output_items, item)
34
- return unless billable?(item)
72
+ return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
35
73
 
36
74
  component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
37
75
  key = if component == :container_session && item["container_id"]
@@ -42,8 +80,10 @@ module LlmCostTracker
42
80
  output_items[key] = item
43
81
  end
44
82
 
45
- def build_line_item(item)
46
- component_key = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
83
+ def build_line_item(item, request: nil, model: nil)
84
+ return nil unless item.is_a?(Hash)
85
+
86
+ component_key = component_key_for(item, request: request, model: model)
47
87
  return nil unless component_key
48
88
 
49
89
  provider_item_id = if component_key == :container_session
@@ -56,12 +96,44 @@ module LlmCostTracker
56
96
  quantity: 1,
57
97
  cost_status: Billing::CostStatus::UNKNOWN,
58
98
  pricing_basis: :provider_usage,
59
- provider_field: "response.output.#{item['type']}",
99
+ provider_field: item["provider_field"] || "response.output.#{item['type']}",
60
100
  provider_item_id: provider_item_id,
61
101
  details: line_item_details(item)
62
102
  )
63
103
  end
64
104
 
105
+ def component_key_for(item, request:, model:)
106
+ component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
107
+ return component unless component == :web_search_request
108
+ return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
109
+
110
+ reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
111
+ end
112
+
113
+ def web_search_preview_used?(request)
114
+ tools = request && (request[:tools] || request["tools"])
115
+ return false unless tools.respond_to?(:each)
116
+
117
+ tools.any? do |tool|
118
+ type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
119
+ type.to_s.include?("web_search_preview")
120
+ end
121
+ end
122
+
123
+ def chat_completions_search_model?(model)
124
+ return false unless model
125
+
126
+ name = model.to_s.split("/", 2).last
127
+ LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(name)
128
+ end
129
+
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)
135
+ end
136
+
65
137
  def line_item_details(item)
66
138
  {
67
139
  "status" => item["status"],
@@ -70,17 +142,13 @@ module LlmCostTracker
70
142
  }.compact
71
143
  end
72
144
 
73
- def openai_service_line_items(response)
74
- line_items_from_output(response["output"])
75
- end
76
-
77
- def openai_stream_service_line_items(events)
145
+ def openai_stream_service_line_items(events, request: nil, model: nil)
78
146
  output_items = []
79
147
  each_event_data(events) do |data|
80
148
  output_items.concat(Array(data.dig("response", "output")))
81
149
  output_items << data["item"] if data["item"]
82
150
  end
83
- line_items_from_output(output_items)
151
+ line_items_from_output(output_items, request: request, model: model)
84
152
  end
85
153
  end
86
154
  end
@@ -1,17 +1,29 @@
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/
12
+ class << self
13
+ def combined_pricing_mode(host:, model:, service_tier:)
14
+ modes = [Pricing.normalize_mode(service_tier)]
15
+ modes << "data_residency" if regional_processing?(host: host, model: model)
16
+ modes = modes.compact.uniq
17
+ modes.empty? ? nil : modes.join("_")
18
+ end
11
19
 
12
- private
20
+ def regional_processing?(host:, model:)
21
+ LlmCostTracker::Providers::Openai::Hosts.data_residency?(host) &&
22
+ LlmCostTracker::Providers::Openai::ModelFamilies.data_residency?(model)
23
+ end
24
+ end
13
25
 
14
- def parse_openai_usage(request_url:, request_body:, response_status:, response_body:)
26
+ def parse(request_url:, request_body:, response_status:, response_body:, **)
15
27
  return nil unless response_status == 200
16
28
 
17
29
  response = safe_json_parse(response_body)
@@ -23,7 +35,7 @@ module LlmCostTracker
23
35
 
24
36
  model = response["model"] || request["model"]
25
37
 
26
- UsageCapture.build(
38
+ Event.build(
27
39
  provider: provider_for(request_url),
28
40
  provider_response_id: response["id"],
29
41
  pricing_mode: pricing_mode(
@@ -32,101 +44,141 @@ module LlmCostTracker
32
44
  service_tier: response["service_tier"] || request["service_tier"]
33
45
  ),
34
46
  model: model,
35
- token_usage: token_usage(usage: usage, cache_read: cache_read),
47
+ token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
36
48
  usage_source: :response,
37
- service_line_items: openai_service_line_items(response)
49
+ service_line_items: service_line_items_for(response, request: request, model: response["model"])
38
50
  )
39
51
  end
40
52
 
41
- 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: [], **)
42
54
  return nil unless response_status == 200
43
55
 
44
56
  request = safe_json_parse(request_body)
45
- model = find_event_value(events) { |data| data["model"] || data.dig("response", "model") } || request["model"]
46
57
  usage = detect_stream_usage(events)
47
- response_id = find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
48
- pricing_mode = pricing_mode(
49
- request_url: request_url,
58
+ context = stream_capture_context(events: events, request: request, request_url: request_url)
59
+
60
+ return build_known_stream_usage(usage: usage, **context) if usage
61
+
62
+ warn_missing_stream_usage(request_url: request_url, request: request)
63
+ build_unknown_stream_usage(**context)
64
+ end
65
+
66
+ def auto_enable_stream_usage?(request_url)
67
+ openai_chat_completions_url?(request_url)
68
+ end
69
+
70
+ private
71
+
72
+ def stream_capture_context(events:, request:, request_url:)
73
+ model = find_event_value(events) do |data|
74
+ data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
75
+ end || request["model"]
76
+ {
77
+ provider: provider_for(request_url),
50
78
  model: model,
51
- service_tier: stream_pricing_mode(events) || request["service_tier"]
52
- )
53
- service_line_items = openai_stream_service_line_items(events)
54
-
55
- if usage
56
- cache_read = cache_read_input_tokens(usage)
57
- UsageCapture.build(
58
- provider: provider_for(request_url),
59
- provider_response_id: response_id,
60
- pricing_mode: pricing_mode,
61
- model: model,
62
- token_usage: token_usage(usage: usage, cache_read: cache_read),
63
- stream: true,
64
- usage_source: :stream_final,
65
- service_line_items: service_line_items
66
- )
67
- else
68
- build_unknown_stream_usage(
69
- provider: provider_for(request_url),
79
+ provider_response_id: find_event_value(events) do |data|
80
+ data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
81
+ end,
82
+ pricing_mode: pricing_mode(
83
+ request_url: request_url,
70
84
  model: model,
71
- provider_response_id: response_id,
72
- pricing_mode: pricing_mode,
73
- service_line_items: service_line_items
74
- )
75
- end
85
+ service_tier: stream_pricing_mode(events) || request["service_tier"]
86
+ ),
87
+ service_line_items: openai_stream_service_line_items(events, request: request, model: model)
88
+ }
89
+ end
90
+
91
+ def build_known_stream_usage(usage:, provider:, model:, provider_response_id:, pricing_mode:, service_line_items:)
92
+ cache_read = cache_read_input_tokens(usage)
93
+ Event.build(
94
+ provider: provider,
95
+ provider_response_id: provider_response_id,
96
+ pricing_mode: pricing_mode,
97
+ model: model,
98
+ token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
99
+ stream: true,
100
+ usage_source: :stream_final,
101
+ service_line_items: service_line_items
102
+ )
103
+ end
104
+
105
+ def warn_missing_stream_usage(request_url:, request:)
106
+ return unless request.is_a?(Hash) && request["stream"]
107
+ return unless openai_chat_completions_url?(request_url)
108
+ return if request.dig("stream_options", "include_usage")
109
+
110
+ Logging.warn(
111
+ "OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
112
+ "Set `stream_options: { include_usage: true }` in your request body so the gem can " \
113
+ "record token counts. This call was stored with usage_source=unknown."
114
+ )
115
+ end
116
+
117
+ def openai_chat_completions_url?(request_url)
118
+ uri = parsed_uri(request_url)
119
+ uri && uri.path.to_s.end_with?("/chat/completions")
76
120
  end
77
121
 
78
122
  def detect_stream_usage(events)
79
123
  find_event_value(events, reverse: true) do |data|
80
- usage = data["usage"] || data.dig("response", "usage")
124
+ usage = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage")
81
125
  usage if usage.is_a?(Hash)
82
126
  end
83
127
  end
84
128
 
85
129
  def stream_pricing_mode(events)
86
130
  find_event_value(events, reverse: true) do |data|
87
- data["service_tier"] || data.dig("response", "service_tier")
131
+ data["service_tier"] || data.dig("response", "service_tier") || data.dig("chunk", "service_tier")
88
132
  end
89
133
  end
90
134
 
91
135
  def pricing_mode(request_url:, model:, service_tier:)
92
- modes = [Pricing.normalize_mode(service_tier)]
93
- modes << "data_residency" if openai_regional_processing?(request_url: request_url, model: model)
94
- modes = modes.compact.uniq
95
- modes.empty? ? nil : modes.join("_")
136
+ OpenaiUsage.combined_pricing_mode(host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier)
96
137
  end
97
138
 
98
- def openai_regional_processing?(request_url:, model:)
99
- uri = parsed_uri(request_url)
100
- return false unless uri&.host.to_s.downcase.match?(OPENAI_DATA_RESIDENCY_HOST_PATTERN)
101
-
102
- openai_data_residency_model?(model)
103
- end
104
-
105
- def openai_data_residency_model?(model)
106
- model.to_s.match?(/\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro))?(?:-\d{4}-\d{2}-\d{2})?\z/)
107
- end
108
-
109
- def token_usage(usage:, cache_read:)
139
+ def token_usage(usage:, cache_read:, model: nil)
110
140
  audio_input = audio_input_tokens(usage)
111
141
  audio_output = audio_output_tokens(usage)
142
+ image_input = image_input_tokens(usage)
143
+ image_output_details = image_output_tokens(usage)
144
+ text_output_details = text_output_tokens(usage)
145
+ raw_output = (usage["completion_tokens"] || usage["output_tokens"]).to_i
146
+ image_output, regular_output_remainder = split_stream_image_output(
147
+ raw_output: raw_output, image_output_details: image_output_details,
148
+ text_output_details: text_output_details, audio_output: audio_output,
149
+ default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
150
+ )
112
151
 
113
152
  TokenUsage.build(
114
- input_tokens: regular_input_tokens(usage: usage, cache_read: cache_read, audio_input: audio_input),
115
- output_tokens: regular_output_tokens(usage: usage, audio_output: audio_output),
153
+ input_tokens: regular_input_tokens(
154
+ usage: usage, cache_read: cache_read, audio_input: audio_input, image_input: image_input
155
+ ),
156
+ output_tokens: regular_output_remainder,
116
157
  total_tokens: usage["total_tokens"],
117
158
  cache_read_input_tokens: cache_read,
118
159
  audio_input_tokens: audio_input,
119
160
  audio_output_tokens: audio_output,
161
+ image_input_tokens: image_input,
162
+ image_output_tokens: image_output,
120
163
  hidden_output_tokens: hidden_output_tokens(usage)
121
164
  )
122
165
  end
123
166
 
124
- def regular_input_tokens(usage:, cache_read:, audio_input:)
125
- [(usage["prompt_tokens"] || usage["input_tokens"]).to_i - cache_read - audio_input, 0].max
167
+ def split_stream_image_output(raw_output:, image_output_details:, text_output_details:, audio_output:,
168
+ default_to_image: false)
169
+ if image_output_details.zero? && text_output_details.zero?
170
+ remainder = [raw_output - audio_output, 0].max
171
+ return default_to_image ? [remainder, 0] : [0, remainder]
172
+ end
173
+
174
+ text_output = text_output_details
175
+ text_output = [raw_output - image_output_details - audio_output, 0].max if text_output.zero?
176
+ [image_output_details, text_output]
126
177
  end
127
178
 
128
- def regular_output_tokens(usage:, audio_output:)
129
- [(usage["completion_tokens"] || usage["output_tokens"]).to_i - audio_output, 0].max
179
+ def regular_input_tokens(usage:, cache_read:, audio_input:, image_input:)
180
+ raw = (usage["prompt_tokens"] || usage["input_tokens"]).to_i
181
+ [raw - cache_read - audio_input - image_input, 0].max
130
182
  end
131
183
 
132
184
  def cache_read_input_tokens(usage)
@@ -149,6 +201,21 @@ module LlmCostTracker
149
201
  details["audio_tokens"].to_i
150
202
  end
151
203
 
204
+ def image_input_tokens(usage)
205
+ details = input_token_details(usage)
206
+ details["image_tokens"].to_i
207
+ end
208
+
209
+ def image_output_tokens(usage)
210
+ details = output_token_details(usage)
211
+ details["image_tokens"].to_i
212
+ end
213
+
214
+ def text_output_tokens(usage)
215
+ details = output_token_details(usage)
216
+ details["text_tokens"].to_i
217
+ end
218
+
152
219
  def input_token_details(usage)
153
220
  usage["prompt_tokens_details"] || usage["input_tokens_details"] || usage["input_token_details"] || {}
154
221
  end
@@ -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