llm_cost_tracker 0.11.0 → 0.12.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/core_ext/object/blank"
4
- require "json"
5
- require "uri"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- module UrlMatchers
10
- def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
11
- uri_matches?(url) do |uri|
12
- host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
13
- path_match = path_matches?(
14
- uri,
15
- exact_paths: exact_paths,
16
- path_includes: path_includes,
17
- path_suffixes: path_suffixes,
18
- path_pattern: path_pattern
19
- )
20
- extra_match = block_given? ? yield(uri) : true
21
-
22
- next false unless host_match && path_match
23
- next false unless extra_match
24
-
25
- true
26
- end
27
- end
28
-
29
- def uri_matches?(url)
30
- uri = parsed_uri(url)
31
- uri ? yield(uri) : false
32
- end
33
-
34
- def parsed_uri(url)
35
- URI.parse(url.to_s)
36
- rescue URI::InvalidURIError
37
- nil
38
- end
39
-
40
- def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
41
- path = uri.path.to_s
42
- matches = true
43
-
44
- matches &&= exact_paths.include?(path) if exact_paths
45
- matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
46
- matches &&= path.match?(path_pattern) if path_pattern
47
-
48
- matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
49
-
50
- matches
51
- end
52
- end
53
-
54
- class Base
55
- extend UrlMatchers
56
- include UrlMatchers
57
-
58
- class << self
59
- def match?(_url)
60
- raise NotImplementedError
61
- end
62
-
63
- def provider_names
64
- []
65
- end
66
- end
67
-
68
- def parse(**)
69
- raise NotImplementedError
70
- end
71
-
72
- def streaming_request?(_request_url, request_parsed)
73
- request_parsed.is_a?(Hash) && request_parsed["stream"] == true
74
- end
75
-
76
- def model_for(_request_url, request_parsed)
77
- request_parsed["model"] if request_parsed.is_a?(Hash)
78
- end
79
-
80
- def parse_stream(**)
81
- nil
82
- end
83
-
84
- def auto_enable_stream_usage?(_request_url)
85
- false
86
- end
87
-
88
- def safe_json_parse(body)
89
- return {} if body.blank?
90
-
91
- JSON.parse(body)
92
- rescue JSON::ParserError
93
- {}
94
- end
95
-
96
- private
97
-
98
- def each_event_data(events, reverse: false)
99
- enumerator = reverse ? events.reverse_each : events.each
100
-
101
- enumerator.each do |event|
102
- data = event[:data]
103
- yield data if data.is_a?(Hash)
104
- end
105
- end
106
-
107
- def find_event_value(events, reverse: false)
108
- each_event_data(events, reverse:) do |data|
109
- value = yield(data)
110
- return value if value.present?
111
- end
112
-
113
- nil
114
- end
115
-
116
- def build_unknown_stream_usage(provider:, model:, provider_response_id:, pricing_mode: nil,
117
- service_line_items: nil)
118
- Event.build(
119
- provider: provider,
120
- provider_response_id: provider_response_id,
121
- pricing_mode: pricing_mode,
122
- model: model || Event::UNKNOWN_MODEL,
123
- token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
124
- stream: true,
125
- usage_source: :unknown,
126
- service_line_items: service_line_items
127
- )
128
- end
129
- end
130
- end
131
- end
@@ -1,230 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../billing/line_item"
4
- require_relative "base"
5
- require_relative "../providers/gemini/model_families"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- class Gemini < Base
10
- HOSTS = %w[generativelanguage.googleapis.com].freeze
11
- TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
12
- STREAM_PATH_PATTERN = /:streamGenerateContent\z/
13
-
14
- class << self
15
- def match?(url)
16
- match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
17
- end
18
-
19
- def provider_names
20
- %w[gemini]
21
- end
22
- end
23
-
24
- def streaming_request?(request_url, request_parsed)
25
- return true if match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
26
-
27
- super
28
- end
29
-
30
- def parse(request_url:, request_body:, response_status:, response_body:, response_headers: nil)
31
- return nil unless response_status == 200
32
-
33
- response = safe_json_parse(response_body)
34
- usage = response["usageMetadata"]
35
- return nil unless usage
36
-
37
- request = safe_json_parse(request_body)
38
- model = extract_model_from_url(request_url)
39
- build_event(
40
- request_url: request_url,
41
- usage: usage,
42
- usage_source: :response,
43
- provider_response_id: response["responseId"],
44
- pricing_mode: pricing_mode(request: request, response_headers: response_headers),
45
- service_line_items: grounding_line_items(grounding_request_count(response["candidates"]), model: model)
46
- )
47
- end
48
-
49
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], response_headers: nil)
50
- return nil unless response_status == 200
51
-
52
- request = safe_json_parse(request_body)
53
- usage = merged_stream_usage(events)
54
- model = extract_model_from_url(request_url)
55
- response_id = stream_response_id(events)
56
- mode = pricing_mode(request: request, response_headers: response_headers)
57
- service_line_items = grounding_line_items_for_stream(events, model: model)
58
-
59
- if usage
60
- build_event(
61
- request_url: request_url,
62
- usage: usage,
63
- stream: true,
64
- usage_source: :stream_final,
65
- provider_response_id: response_id,
66
- pricing_mode: mode,
67
- service_line_items: service_line_items
68
- )
69
- else
70
- build_unknown_stream_usage(
71
- provider: "gemini",
72
- model: model,
73
- provider_response_id: response_id,
74
- pricing_mode: mode,
75
- service_line_items: service_line_items
76
- )
77
- end
78
- end
79
-
80
- def model_for(request_url, _request_parsed)
81
- extract_model_from_url(request_url)
82
- end
83
-
84
- def provider_for(_request_url)
85
- "gemini"
86
- end
87
-
88
- private
89
-
90
- def build_event(request_url:, usage:, usage_source:, stream: false, provider_response_id: nil,
91
- pricing_mode: nil, service_line_items: nil)
92
- cache_read = usage["cachedContentTokenCount"].to_i
93
- tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
94
- audio_input = audio_input_tokens(usage)
95
- audio_output = audio_output_tokens(usage)
96
-
97
- Event.build(
98
- provider: "gemini",
99
- model: extract_model_from_url(request_url),
100
- pricing_mode: pricing_mode,
101
- token_usage: TokenUsage.build(
102
- input_tokens: regular_input_tokens(usage: usage, cache_read: cache_read, audio_input: audio_input) +
103
- tool_use_prompt,
104
- output_tokens: regular_output_tokens(usage: usage, audio_output: audio_output),
105
- total_tokens: usage["totalTokenCount"],
106
- cache_read_input_tokens: cache_read,
107
- audio_input_tokens: audio_input,
108
- audio_output_tokens: audio_output,
109
- hidden_output_tokens: usage["thoughtsTokenCount"]
110
- ),
111
- stream: stream,
112
- usage_source: usage_source,
113
- provider_response_id: provider_response_id,
114
- service_line_items: service_line_items
115
- )
116
- end
117
-
118
- def merged_stream_usage(events)
119
- find_event_value(events, reverse: true) do |data|
120
- meta = data["usageMetadata"]
121
- meta if meta.is_a?(Hash)
122
- end
123
- end
124
-
125
- def output_tokens(usage)
126
- (usage["candidatesTokenCount"] || usage["responseTokenCount"]).to_i + usage["thoughtsTokenCount"].to_i
127
- end
128
-
129
- def regular_input_tokens(usage:, cache_read:, audio_input:)
130
- [usage["promptTokenCount"].to_i - cache_read - audio_input, 0].max
131
- end
132
-
133
- def regular_output_tokens(usage:, audio_output:)
134
- [output_tokens(usage) - audio_output, 0].max
135
- end
136
-
137
- def audio_input_tokens(usage)
138
- prompt_audio = modality_tokens(usage["promptTokensDetails"] || usage["prompt_tokens_details"], "AUDIO")
139
- cache_audio = modality_tokens(usage["cacheTokensDetails"] || usage["cache_tokens_details"], "AUDIO")
140
- [prompt_audio - cache_audio, 0].max
141
- end
142
-
143
- def audio_output_tokens(usage)
144
- modality_tokens(
145
- usage["candidatesTokensDetails"] ||
146
- usage["candidates_tokens_details"] ||
147
- usage["responseTokensDetails"] ||
148
- usage["response_tokens_details"],
149
- "AUDIO"
150
- )
151
- end
152
-
153
- def modality_tokens(details, modality)
154
- Array(details).sum do |detail|
155
- next 0 unless detail.is_a?(Hash) && detail["modality"] == modality
156
-
157
- (detail["tokenCount"] || detail["token_count"]).to_i
158
- end
159
- end
160
-
161
- def stream_response_id(events)
162
- find_event_value(events) { |data| data["responseId"] }
163
- end
164
-
165
- def extract_model_from_url(url)
166
- uri = parsed_uri(url)
167
- return nil unless uri
168
-
169
- match = uri.path.match(%r{/models/([^/:]+)})
170
- match && match[1]
171
- end
172
-
173
- def pricing_mode(request:, response_headers:)
174
- response_tier = response_header(response_headers, "x-gemini-service-tier")
175
- response_mode = Pricing::Mode.normalize(response_tier)
176
- return response_mode if response_mode
177
-
178
- request_mode = Pricing::Mode.normalize(
179
- request["service_tier"] ||
180
- request["serviceTier"] ||
181
- request.dig("config", "service_tier") ||
182
- request.dig("config", "serviceTier")
183
- )
184
- request_mode == :flex ? request_mode : nil
185
- end
186
-
187
- def response_header(headers, name)
188
- headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
189
- end
190
-
191
- def grounding_line_items_for_stream(events, model:)
192
- quantity = find_event_value(events, reverse: true) do |data|
193
- count = grounding_request_count(data["candidates"])
194
- count if count.positive?
195
- end
196
- grounding_line_items(quantity || 0, model: model)
197
- end
198
-
199
- def grounding_request_count(candidates)
200
- Array(candidates).sum do |candidate|
201
- next 0 unless candidate.is_a?(Hash)
202
-
203
- metadata = candidate["groundingMetadata"] || candidate["grounding_metadata"] || {}
204
- queries = metadata["webSearchQueries"] || metadata["web_search_queries"] || []
205
- Array(queries).size
206
- end
207
- end
208
-
209
- def grounding_line_items(query_count, model:)
210
- return [] unless query_count.positive?
211
-
212
- billed_quantity = grounding_billed_quantity(query_count, model: model)
213
- [
214
- Billing::LineItem.build(
215
- component_key: :grounding_request,
216
- quantity: billed_quantity,
217
- cost_status: Billing::CostStatus::UNKNOWN,
218
- pricing_basis: :provider_usage,
219
- provider_field: "response.candidates.groundingMetadata.webSearchQueries",
220
- details: { web_search_queries: query_count }
221
- )
222
- ]
223
- end
224
-
225
- def grounding_billed_quantity(query_count, model:)
226
- LlmCostTracker::Providers::Gemini::ModelFamilies.per_query_grounding?(model) ? query_count : 1
227
- end
228
- end
229
- end
230
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require_relative "openai_usage"
5
- require_relative "../providers/openai/hosts"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- class Openai < Base
10
- include OpenaiUsage
11
-
12
- TRACKED_PATHS = %w[
13
- /v1/chat/completions
14
- /v1/completions
15
- /v1/embeddings
16
- /v1/responses
17
- /v1/images/generations
18
- /v1/images/edits
19
- /v1/images/variations
20
- /v1/audio/transcriptions
21
- /v1/audio/translations
22
- /v1/audio/speech
23
- /v1/moderations
24
- ].freeze
25
-
26
- class << self
27
- def match?(url)
28
- match_uri?(url, hosts: Providers::Openai::Hosts::API_HOSTS, exact_paths: TRACKED_PATHS)
29
- end
30
-
31
- def provider_names
32
- %w[openai]
33
- end
34
- end
35
-
36
- def provider_for(_request_url)
37
- "openai"
38
- end
39
- end
40
- end
41
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require_relative "openai_usage"
5
-
6
- module LlmCostTracker
7
- module Parsers
8
- class OpenaiCompatible < Base
9
- include OpenaiUsage
10
-
11
- TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
12
-
13
- class << self
14
- def match?(url)
15
- match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
16
- end
17
-
18
- def provider_names
19
- providers = LlmCostTracker.configuration.openai_compatible_providers
20
- cached = @provider_names
21
- return cached if cached && @provider_names_providers.equal?(providers)
22
-
23
- names = [
24
- "openai_compatible",
25
- *providers.each_value.map { |provider| provider.to_s.downcase }
26
- ].uniq.freeze
27
- return names unless providers.frozen?
28
-
29
- @provider_names_providers = providers
30
- @provider_names = names
31
- end
32
-
33
- def provider_for_uri(uri)
34
- return nil unless uri
35
-
36
- LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
37
- end
38
- end
39
-
40
- def provider_for(request_url)
41
- self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
42
- end
43
- end
44
- end
45
- end
@@ -1,228 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../providers/openai/hosts"
4
- require_relative "../providers/openai/model_families"
5
- require_relative "../providers/openai/service_charges"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- module OpenaiUsage
10
- include LlmCostTracker::Providers::Openai::ServiceCharges
11
-
12
- class << self
13
- def combined_pricing_mode(host:, model:, service_tier:)
14
- modes = [Pricing::Mode.normalize(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
19
-
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
25
-
26
- def parse(request_url:, request_body:, response_status:, response_body:, **)
27
- return nil unless response_status == 200
28
-
29
- response = safe_json_parse(response_body)
30
- usage = response["usage"]
31
- return nil unless usage
32
-
33
- request = safe_json_parse(request_body)
34
- cache_read = cache_read_input_tokens(usage)
35
-
36
- model = response["model"] || request["model"]
37
-
38
- Event.build(
39
- provider: provider_for(request_url),
40
- provider_response_id: response["id"],
41
- pricing_mode: pricing_mode(
42
- request_url: request_url,
43
- model: model,
44
- service_tier: response["service_tier"] || request["service_tier"]
45
- ),
46
- model: model,
47
- token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
48
- usage_source: :response,
49
- service_line_items: service_line_items_for(response, request: request, model: response["model"])
50
- )
51
- end
52
-
53
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
54
- return nil unless response_status == 200
55
-
56
- request = safe_json_parse(request_body)
57
- usage = detect_stream_usage(events)
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),
78
- model: model,
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,
84
- model: model,
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")
120
- end
121
-
122
- def detect_stream_usage(events)
123
- find_event_value(events, reverse: true) do |data|
124
- usage = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage")
125
- usage if usage.is_a?(Hash)
126
- end
127
- end
128
-
129
- def stream_pricing_mode(events)
130
- find_event_value(events, reverse: true) do |data|
131
- data["service_tier"] || data.dig("response", "service_tier") || data.dig("chunk", "service_tier")
132
- end
133
- end
134
-
135
- def pricing_mode(request_url:, model:, service_tier:)
136
- OpenaiUsage.combined_pricing_mode(host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier)
137
- end
138
-
139
- def token_usage(usage:, cache_read:, model: nil)
140
- audio_input = audio_input_tokens(usage)
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
- )
151
-
152
- TokenUsage.build(
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,
157
- total_tokens: usage["total_tokens"],
158
- cache_read_input_tokens: cache_read,
159
- audio_input_tokens: audio_input,
160
- audio_output_tokens: audio_output,
161
- image_input_tokens: image_input,
162
- image_output_tokens: image_output,
163
- hidden_output_tokens: hidden_output_tokens(usage)
164
- )
165
- end
166
-
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]
177
- end
178
-
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
182
- end
183
-
184
- def cache_read_input_tokens(usage)
185
- details = input_token_details(usage)
186
- details["cached_tokens"].to_i
187
- end
188
-
189
- def audio_input_tokens(usage)
190
- details = input_token_details(usage)
191
- details["audio_tokens"].to_i
192
- end
193
-
194
- def hidden_output_tokens(usage)
195
- details = output_token_details(usage)
196
- details["reasoning_tokens"].to_i
197
- end
198
-
199
- def audio_output_tokens(usage)
200
- details = output_token_details(usage)
201
- details["audio_tokens"].to_i
202
- end
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
-
219
- def input_token_details(usage)
220
- usage["prompt_tokens_details"] || usage["input_tokens_details"] || usage["input_token_details"] || {}
221
- end
222
-
223
- def output_token_details(usage)
224
- usage["completion_tokens_details"] || usage["output_tokens_details"] || usage["output_token_details"] || {}
225
- end
226
- end
227
- end
228
- end