llm_cost_tracker 0.7.2 → 0.8.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +72 -1
  4. data/README.md +58 -221
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +110 -18
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../capture/stream_collector"
5
- require_relative "../capture/stream_tracker"
4
+ require_relative "../billing/line_item"
6
5
 
7
6
  module LlmCostTracker
8
7
  module Integrations
@@ -52,22 +51,48 @@ module LlmCostTracker
52
51
  pricing_mode: pricing_mode(message: message, request: request, usage: usage),
53
52
  token_usage: token_usage(usage: usage, input_tokens: input_tokens, output_tokens: output_tokens),
54
53
  usage_source: :sdk_response,
55
- provider_response_id: object_value(message, :id)
54
+ provider_response_id: object_value(message, :id),
55
+ service_line_items: service_line_items_from(usage)
56
56
  ),
57
57
  latency_ms: latency_ms
58
58
  )
59
59
  end
60
60
  end
61
61
 
62
+ def service_line_items_from(usage)
63
+ server_tool_use = object_value(usage, :server_tool_use)
64
+ return [] unless server_tool_use
65
+
66
+ [
67
+ line_item_for_server_tool(server_tool_use, :web_search_request, :web_search_requests,
68
+ "usage.server_tool_use.web_search_requests"),
69
+ line_item_for_server_tool(server_tool_use, :code_execution_request, :code_execution_requests,
70
+ "usage.server_tool_use.code_execution_requests")
71
+ ].compact
72
+ end
73
+
74
+ def line_item_for_server_tool(server_tool_use, component_key, count_key, provider_field)
75
+ quantity = object_value(server_tool_use, count_key).to_i
76
+ return nil if quantity.zero?
77
+
78
+ Billing::LineItem.build(
79
+ component_key: component_key,
80
+ quantity: quantity,
81
+ cost_status: Billing::CostStatus::UNKNOWN,
82
+ pricing_basis: :provider_usage,
83
+ provider_field: provider_field
84
+ )
85
+ end
86
+
62
87
  def token_usage(usage:, input_tokens:, output_tokens:)
63
- cache_write_1h = object_dig(usage, :cache_creation, :ephemeral_1h_input_tokens).to_i
64
- cache_write_5m = object_dig(usage, :cache_creation, :ephemeral_5m_input_tokens)
65
- cache_write = if cache_write_5m.nil?
66
- total_cache_write = object_value(usage, :cache_creation_input_tokens)
67
- [total_cache_write.to_i - cache_write_1h, 0].max
68
- else
69
- cache_write_5m.to_i
70
- end
88
+ cache_creation = object_value(usage, :cache_creation)
89
+ if cache_creation
90
+ cache_write_default = object_value(cache_creation, :ephemeral_5m_input_tokens).to_i
91
+ cache_write_extended = object_value(cache_creation, :ephemeral_1h_input_tokens).to_i
92
+ else
93
+ cache_write_default = object_value(usage, :cache_creation_input_tokens).to_i
94
+ cache_write_extended = 0
95
+ end
71
96
  hidden_output = (
72
97
  object_value(usage, :thinking_tokens, :thinking_output_tokens) ||
73
98
  object_dig(usage, :output_tokens_details, :reasoning_tokens)
@@ -77,8 +102,8 @@ module LlmCostTracker
77
102
  input_tokens: input_tokens.to_i,
78
103
  output_tokens: output_tokens.to_i,
79
104
  cache_read_input_tokens: object_value(usage, :cache_read_input_tokens).to_i,
80
- cache_write_input_tokens: cache_write,
81
- cache_write_1h_input_tokens: cache_write_1h,
105
+ cache_write_input_tokens: cache_write_default,
106
+ cache_write_extended_input_tokens: cache_write_extended,
82
107
  hidden_output_tokens: hidden_output
83
108
  )
84
109
  end
@@ -95,39 +120,21 @@ module LlmCostTracker
95
120
  modes.empty? ? nil : modes.join("_")
96
121
  end
97
122
 
123
+ def stream_pricing_mode(request)
124
+ pricing_mode(message: nil, request: request || {}, usage: nil)
125
+ end
126
+
98
127
  def inference_geo(message:, request:, usage:)
99
128
  object_value(usage, :inference_geo) ||
100
129
  object_value(message, :inference_geo) ||
101
130
  request[:inference_geo]
102
131
  end
103
-
104
- def track_stream(stream, collector:)
105
- return stream unless active?
106
-
107
- LlmCostTracker::Capture::StreamTracker.new(
108
- stream: stream,
109
- collector: collector,
110
- active: -> { active? },
111
- finish: ->(errored:) { finish_stream(collector, errored: errored) }
112
- ).wrap
113
- end
114
-
115
- def stream_collector(request)
116
- LlmCostTracker::Capture::StreamCollector.new(
117
- provider: "anthropic",
118
- model: request[:model]
119
- )
120
- end
121
-
122
- def finish_stream(collector, errored:)
123
- record_safely { collector.finish!(errored: errored) }
124
- end
125
132
  end
126
133
 
127
134
  module MessagesPatch
128
135
  def create(*args, **kwargs)
129
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
136
  LlmCostTracker::Integrations::Anthropic.enforce_budget!
137
+ started_at = LlmCostTracker::Timing.now_monotonic
131
138
  message = super
132
139
  LlmCostTracker::Integrations::Anthropic.record_message(
133
140
  message,
@@ -139,16 +146,16 @@ module LlmCostTracker
139
146
 
140
147
  def stream(*args, **kwargs)
141
148
  request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
142
- collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
143
149
  LlmCostTracker::Integrations::Anthropic.enforce_budget!
150
+ collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
144
151
  stream = super
145
152
  LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
146
153
  end
147
154
 
148
155
  def stream_raw(*args, **kwargs)
149
156
  request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
150
- collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
151
157
  LlmCostTracker::Integrations::Anthropic.enforce_budget!
158
+ collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
152
159
  stream = super
153
160
  LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
154
161
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/hash/indifferent_access"
4
- require "active_support/core_ext/object/try"
5
4
  require "active_support/core_ext/string/inflections"
6
5
 
7
6
  require_relative "../logging"
7
+ require_relative "../timing"
8
+ require_relative "../capture/stream_collector"
9
+ require_relative "../capture/stream_tracker"
8
10
 
9
11
  module LlmCostTracker
10
12
  module Integrations
@@ -30,17 +32,16 @@ module LlmCostTracker
30
32
  return Result.new(name, :warn, "#{name} integration cannot be installed: #{problems.join('; ')}")
31
33
  end
32
34
 
33
- required_targets = patch_targets.reject { |target| target.fetch(:optional) }
34
- installed = required_targets.count do |target|
35
+ installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
35
36
  target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
36
37
  end
37
- return Result.new(name, :ok, "#{name} integration installed") if installed == required_targets.count
38
+ return Result.new(name, :ok, "#{name} integration installed") if installed
38
39
 
39
40
  Result.new(name, :warn, "#{name} integration is enabled but not installed")
40
41
  end
41
42
 
42
43
  def elapsed_ms(started_at)
43
- ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
44
+ Timing.elapsed_ms(started_at)
44
45
  end
45
46
 
46
47
  def enforce_budget!
@@ -60,6 +61,29 @@ module LlmCostTracker
60
61
  params.merge(kwargs).with_indifferent_access
61
62
  end
62
63
 
64
+ def track_stream(stream, collector:)
65
+ return stream unless active?
66
+
67
+ LlmCostTracker::Capture::StreamTracker.new(
68
+ stream: stream,
69
+ collector: collector,
70
+ active: -> { active? },
71
+ finish: ->(errored:) { record_safely { collector.finish!(errored: errored) } }
72
+ ).wrap
73
+ end
74
+
75
+ def stream_collector(request)
76
+ LlmCostTracker::Capture::StreamCollector.new(
77
+ provider: integration_name.to_s,
78
+ model: request[:model],
79
+ pricing_mode: stream_pricing_mode(request)
80
+ )
81
+ end
82
+
83
+ def stream_pricing_mode(_request)
84
+ nil
85
+ end
86
+
63
87
  def object_value(object, *keys)
64
88
  keys.each do |key|
65
89
  value = read_object_value(object, key)
@@ -69,15 +93,6 @@ module LlmCostTracker
69
93
  end
70
94
 
71
95
  def object_dig(object, *path)
72
- if object.respond_to?(:dig)
73
- begin
74
- value = object.dig(*path)
75
- return value unless value.nil?
76
- rescue NameError, TypeError
77
- nil
78
- end
79
- end
80
-
81
96
  path.reduce(object) do |current, key|
82
97
  return nil if current.nil?
83
98
 
@@ -106,25 +121,17 @@ module LlmCostTracker
106
121
 
107
122
  def read_object_value(object, key)
108
123
  return nil if object.nil?
109
- return object[key] if object.try(:key?, key)
110
-
111
- string_key = key.to_s
112
- return object[string_key] if object.try(:key?, string_key)
113
-
114
- value = object.try(key)
115
- return value unless value.nil?
116
124
 
117
- indexed_object_value(object, key)
118
- end
125
+ if object.is_a?(Hash)
126
+ return object[key] if object.key?(key)
127
+ return object[key.name] if key.is_a?(Symbol) && object.key?(key.name)
128
+ end
119
129
 
120
- def indexed_object_value(object, key)
121
- object.try(:[], key)
122
- rescue IndexError, NameError, TypeError
123
- nil
130
+ object.public_send(key) if object.respond_to?(key)
124
131
  end
125
132
 
126
- module_function :read_object_value, :indexed_object_value
127
- private_class_method :read_object_value, :indexed_object_value
133
+ module_function :read_object_value
134
+ private_class_method :read_object_value
128
135
 
129
136
  def validate_contract!
130
137
  problems = version_problems + target_problems
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
- require_relative "../capture/stream_collector"
5
- require_relative "../capture/stream_tracker"
4
+ require_relative "../billing/line_item"
5
+ require_relative "../parsers/openai_service_charges"
6
6
 
7
7
  module LlmCostTracker
8
8
  module Integrations
@@ -14,6 +14,10 @@ module LlmCostTracker
14
14
  :openai
15
15
  end
16
16
 
17
+ def stream_pricing_mode(request)
18
+ Pricing.normalize_mode((request || {})[:service_tier])
19
+ end
20
+
17
21
  def minimum_version
18
22
  "0.59.0"
19
23
  end
@@ -54,65 +58,106 @@ module LlmCostTracker
54
58
  provider: "openai",
55
59
  model: object_value(response, :model) || request[:model],
56
60
  pricing_mode: object_value(response, :service_tier) || request[:service_tier],
57
- token_usage: TokenUsage.build(
58
- input_tokens: regular_input_tokens(input_tokens, cache_read),
59
- output_tokens: output_tokens.to_i,
60
- cache_read_input_tokens: cache_read,
61
- hidden_output_tokens: hidden_output_tokens(usage)
62
- ),
61
+ token_usage: token_usage(usage:, input_tokens:, output_tokens:, cache_read:),
63
62
  usage_source: :sdk_response,
64
- provider_response_id: object_value(response, :id)
63
+ provider_response_id: object_value(response, :id),
64
+ service_line_items: service_line_items_from(response)
65
65
  ),
66
66
  latency_ms: latency_ms
67
67
  )
68
68
  end
69
69
  end
70
70
 
71
+ def service_line_items_from(response)
72
+ output = object_value(response, :output)
73
+ return [] unless output.respond_to?(:each)
74
+
75
+ LlmCostTracker::Parsers::OpenaiServiceCharges
76
+ .line_items_from_output(output.map { |item| normalize_output_item(item) })
77
+ end
78
+
79
+ def normalize_output_item(item)
80
+ return item if item.is_a?(Hash)
81
+ return nil if item.nil?
82
+
83
+ {
84
+ "type" => object_value(item, :type),
85
+ "id" => object_value(item, :id),
86
+ "status" => object_value(item, :status),
87
+ "container_id" => object_value(item, :container_id),
88
+ "action" => normalize_output_action(object_value(item, :action))
89
+ }
90
+ end
91
+
92
+ def normalize_output_action(action)
93
+ return nil if action.nil?
94
+ return action if action.is_a?(Hash)
95
+
96
+ { "type" => object_value(action, :type) }
97
+ end
98
+
99
+ def token_usage(usage:, input_tokens:, output_tokens:, cache_read:)
100
+ audio_input = audio_input_tokens(usage)
101
+ audio_output = audio_output_tokens(usage)
102
+
103
+ TokenUsage.build(
104
+ input_tokens: regular_input_tokens(input_tokens, cache_read, audio_input),
105
+ output_tokens: regular_output_tokens(output_tokens, audio_output),
106
+ cache_read_input_tokens: cache_read,
107
+ audio_input_tokens: audio_input,
108
+ audio_output_tokens: audio_output,
109
+ hidden_output_tokens: hidden_output_tokens(usage)
110
+ )
111
+ end
112
+
113
+ INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
114
+ OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
115
+
71
116
  def cache_read_input_tokens(usage)
72
- (
73
- object_dig(usage, :input_tokens_details, :cached_tokens) ||
74
- object_dig(usage, :prompt_tokens_details, :cached_tokens)
75
- ).to_i
117
+ input_detail(usage, :cached_tokens)
76
118
  end
77
119
 
78
120
  def hidden_output_tokens(usage)
79
- (
80
- object_dig(usage, :output_tokens_details, :reasoning_tokens) ||
81
- object_dig(usage, :completion_tokens_details, :reasoning_tokens)
82
- ).to_i
121
+ output_detail(usage, :reasoning_tokens)
83
122
  end
84
123
 
85
- def regular_input_tokens(input_tokens, cache_read)
86
- [input_tokens.to_i - cache_read.to_i, 0].max
124
+ def audio_input_tokens(usage)
125
+ input_detail(usage, :audio_tokens)
87
126
  end
88
127
 
89
- def track_stream(stream, collector:)
90
- return stream unless active?
128
+ def audio_output_tokens(usage)
129
+ output_detail(usage, :audio_tokens)
130
+ end
91
131
 
92
- LlmCostTracker::Capture::StreamTracker.new(
93
- stream: stream,
94
- collector: collector,
95
- active: -> { active? },
96
- finish: ->(errored:) { finish_stream(collector, errored: errored) }
97
- ).wrap
132
+ def input_detail(usage, key)
133
+ INPUT_DETAIL_KEYS.each do |container|
134
+ value = object_dig(usage, container, key)
135
+ return value.to_i if value
136
+ end
137
+ 0
98
138
  end
99
139
 
100
- def stream_collector(request)
101
- LlmCostTracker::Capture::StreamCollector.new(
102
- provider: "openai",
103
- model: request[:model]
104
- )
140
+ def output_detail(usage, key)
141
+ OUTPUT_DETAIL_KEYS.each do |container|
142
+ value = object_dig(usage, container, key)
143
+ return value.to_i if value
144
+ end
145
+ 0
146
+ end
147
+
148
+ def regular_input_tokens(input_tokens, cache_read, audio_input)
149
+ [input_tokens.to_i - cache_read - audio_input, 0].max
105
150
  end
106
151
 
107
- def finish_stream(collector, errored:)
108
- record_safely { collector.finish!(errored: errored) }
152
+ def regular_output_tokens(output_tokens, audio_output)
153
+ [output_tokens.to_i - audio_output, 0].max
109
154
  end
110
155
  end
111
156
 
112
157
  module ResponsesPatch
113
158
  def create(*args, **kwargs)
114
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
159
  LlmCostTracker::Integrations::Openai.enforce_budget!
160
+ started_at = LlmCostTracker::Timing.now_monotonic
116
161
  response = super
117
162
  LlmCostTracker::Integrations::Openai.record_response(
118
163
  response,
@@ -124,25 +169,25 @@ module LlmCostTracker
124
169
 
125
170
  def stream(*args, **kwargs)
126
171
  request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
127
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
128
172
  LlmCostTracker::Integrations::Openai.enforce_budget!
173
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
129
174
  stream = super
130
175
  LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
131
176
  end
132
177
 
133
178
  def stream_raw(*args, **kwargs)
134
179
  request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
135
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
136
180
  LlmCostTracker::Integrations::Openai.enforce_budget!
181
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
137
182
  stream = super
138
183
  LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
139
184
  end
140
185
 
141
186
  def retrieve_streaming(response_id, *args, **kwargs)
142
187
  request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
188
+ LlmCostTracker::Integrations::Openai.enforce_budget!
143
189
  collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
144
190
  collector.provider_response_id = response_id
145
- LlmCostTracker::Integrations::Openai.enforce_budget!
146
191
  stream = super
147
192
  LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
148
193
  end
@@ -150,8 +195,8 @@ module LlmCostTracker
150
195
 
151
196
  module ChatCompletionsPatch
152
197
  def create(*args, **kwargs)
153
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
154
198
  LlmCostTracker::Integrations::Openai.enforce_budget!
199
+ started_at = LlmCostTracker::Timing.now_monotonic
155
200
  response = super
156
201
  LlmCostTracker::Integrations::Openai.record_response(
157
202
  response,
@@ -163,8 +208,8 @@ module LlmCostTracker
163
208
 
164
209
  def stream_raw(*args, **kwargs)
165
210
  request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
166
- collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
167
211
  LlmCostTracker::Integrations::Openai.enforce_budget!
212
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
168
213
  stream = super
169
214
  LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
170
215
  end
@@ -89,7 +89,7 @@ module LlmCostTracker
89
89
  hidden_output_tokens: hidden_output
90
90
  ),
91
91
  stream: stream,
92
- usage_source: :ruby_llm,
92
+ usage_source: :sdk_response,
93
93
  provider_response_id: provider_response_id(response)
94
94
  ),
95
95
  latency_ms: latency_ms
@@ -98,7 +98,7 @@ module LlmCostTracker
98
98
  end
99
99
 
100
100
  def regular_input_tokens(input_tokens, cache_read)
101
- [input_tokens.to_i - cache_read.to_i, 0].max
101
+ [input_tokens.to_i - cache_read, 0].max
102
102
  end
103
103
 
104
104
  def provider_slug(provider)
@@ -133,8 +133,8 @@ module LlmCostTracker
133
133
  def complete(*args, **kwargs, &)
134
134
  integration = LlmCostTracker::Integrations::RubyLlm
135
135
  request = integration.request_params(args, kwargs)
136
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
136
  integration.enforce_budget!
137
+ started_at = LlmCostTracker::Timing.now_monotonic
138
138
  response = super
139
139
  integration.record_completion(
140
140
  self,
@@ -149,8 +149,8 @@ module LlmCostTracker
149
149
  def embed(*args, **kwargs)
150
150
  integration = LlmCostTracker::Integrations::RubyLlm
151
151
  request = integration.request_params(args, kwargs)
152
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
153
152
  integration.enforce_budget!
153
+ started_at = LlmCostTracker::Timing.now_monotonic
154
154
  response = super
155
155
  integration.record_embedding(
156
156
  self,
@@ -164,8 +164,8 @@ module LlmCostTracker
164
164
  def transcribe(*args, **kwargs)
165
165
  integration = LlmCostTracker::Integrations::RubyLlm
166
166
  request = integration.request_params(args, kwargs)
167
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
167
  integration.enforce_budget!
168
+ started_at = LlmCostTracker::Timing.now_monotonic
169
169
  response = super
170
170
  integration.record_transcription(
171
171
  self,
@@ -26,11 +26,11 @@ module LlmCostTracker
26
26
  end
27
27
 
28
28
  def normalize(names)
29
- Array(names).flatten.map(&:to_sym).uniq
29
+ Array(names).flatten.uniq
30
30
  end
31
31
 
32
32
  def fetch(name)
33
- AVAILABLE.fetch(name.to_sym) do
33
+ AVAILABLE.fetch(name) do
34
34
  message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
35
35
  raise LlmCostTracker::Error, message
36
36
  end
@@ -27,38 +27,41 @@ module LlmCostTracker
27
27
 
28
28
  def snapshot_totals
29
29
  values = periods.to_h { |period| [period, 0.0] }
30
+ period_by_name = periods.to_h { |period| [period.name, period] }
30
31
  sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
31
- LlmCostTracker::Ledger::Call.find_by_sql(sql).each do |row|
32
- values[row.period_key.to_sym] = row.total_cost.to_f
32
+ LlmCostTracker::Call.find_by_sql(sql).each do |row|
33
+ period = period_by_name.fetch(row.period_key)
34
+ values[period] = row.total_cost.to_f
33
35
  end
34
36
  values
35
37
  end
36
38
 
37
39
  def snapshot_select(period)
38
40
  start = Period.range_start(period, time)
39
- "SELECT #{connection.quote(period.to_s)} AS period_key, " \
41
+ "SELECT #{connection.quote(period.name)} AS period_key, " \
40
42
  "(#{rollup_total_sql(period)}) + (#{pending_total_sql(start)}) AS total_cost"
41
43
  end
42
44
 
43
45
  def rollup_total_sql(period)
44
- table = connection.quote_table_name("llm_cost_tracker_period_totals")
45
- "COALESCE((SELECT total_cost FROM #{table} " \
46
+ table = connection.quote_table_name("llm_cost_tracker_call_rollups")
47
+ "COALESCE((SELECT SUM(total_cost) FROM #{table} " \
46
48
  "WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
47
- "AND period_start = #{connection.quote(Period.bucket(period, time))} LIMIT 1), 0)"
49
+ "AND period_start = #{connection.quote(Period.bucket(period, time))} " \
50
+ "AND currency = #{connection.quote(Ledger::Rollups::DEFAULT_CURRENCY)}), 0)"
48
51
  end
49
52
 
50
53
  def pending_total_sql(start)
51
- table = connection.quote_table_name(Ingestion::Event.table_name)
54
+ table = connection.quote_table_name(Ingestion::InboxEntry.table_name)
52
55
  total_cost = connection.quote_column_name("total_cost")
53
56
  tracked_at = connection.quote_column_name("tracked_at")
54
57
  attempts = connection.quote_column_name("attempts")
55
58
  "COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
56
- "WHERE #{attempts} < #{Ingestion::Event::MAX_ATTEMPTS} " \
59
+ "WHERE #{attempts} < #{Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE} " \
57
60
  "AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
58
61
  end
59
62
 
60
63
  def connection
61
- LlmCostTracker::Ledger::Call.connection
64
+ LlmCostTracker::Call.connection
62
65
  end
63
66
  end
64
67
  end
@@ -4,22 +4,22 @@ module LlmCostTracker
4
4
  module Ledger
5
5
  module Period
6
6
  PERIODS = {
7
- monthly: "month",
8
- daily: "day"
7
+ month: "month",
8
+ day: "day"
9
9
  }.freeze
10
10
 
11
11
  module_function
12
12
 
13
13
  def valid_keys(periods)
14
- periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
14
+ periods.select { |period| PERIODS.key?(period) }
15
15
  end
16
16
 
17
17
  def range_start(period, time)
18
18
  utc_time = time.to_time.utc
19
19
 
20
20
  case period
21
- when :monthly then utc_time.beginning_of_month
22
- when :daily then utc_time.beginning_of_day
21
+ when :month then utc_time.beginning_of_month
22
+ when :day then utc_time.beginning_of_day
23
23
  end
24
24
  end
25
25
 
@@ -6,12 +6,8 @@ module LlmCostTracker
6
6
  module Ledger
7
7
  class Rollups
8
8
  class UpsertSql
9
- def self.call(model)
10
- new(model).call
11
- end
12
-
13
- def initialize(model)
14
- @model = model
9
+ def self.call
10
+ new.call
15
11
  end
16
12
 
17
13
  def call
@@ -23,13 +19,11 @@ module LlmCostTracker
23
19
 
24
20
  private
25
21
 
26
- attr_reader :model
27
-
28
22
  def postgres_sql
29
23
  total_cost = connection.quote_column_name("total_cost")
30
24
  updated_at = connection.quote_column_name("updated_at")
31
25
 
32
- "#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
26
+ "#{total_cost} = #{LlmCostTracker::CallRollup.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
33
27
  "#{updated_at} = excluded.#{updated_at}"
34
28
  end
35
29
 
@@ -38,7 +32,7 @@ module LlmCostTracker
38
32
  end
39
33
 
40
34
  def connection
41
- model.connection
35
+ LlmCostTracker::CallRollup.connection
42
36
  end
43
37
  end
44
38
  end