Module: LlmCostTracker::Providers::Openai::ServiceCharges

Included in:
ResponseParser
Defined in:
lib/llm_cost_tracker/providers/openai/service_charges.rb

Constant Summary collapse

RESPONSE_OUTPUT_RENAMES =
{
  "web_search_call" => "web_search_request",
  "code_interpreter_call" => "container_session"
}.freeze
CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD =
"choices.message.annotations.url_citation"
CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD =
"request.model"

Class Method Summary collapse

Class Method Details

.billable?(item) ⇒ Boolean

Returns:

  • (Boolean)


58
59
60
61
62
63
64
65
66
67
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 58

def billable?(item)
  return false unless item.is_a?(Hash)

  dimension = output_dimension(item["type"])
  return false unless dimension
  return true unless dimension == "web_search_request"

  action_type = item.dig("action", "type")
  action_type.nil? || action_type == "search"
end

.build_line_item(item, request: nil, model: nil) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 83

def build_line_item(item, request: nil, model: nil)
  return nil unless item.is_a?(Hash)

  dimension_key = dimension_key_for(item, request: request, model: model)
  return nil unless dimension_key

  provider_item_id = if dimension_key == "container_session"
                       item["container_id"] || item["id"]
                     else
                       item["id"]
                     end
  Charges::LineItem.build(
    dimension_key: dimension_key,
    quantity: 1,
    cost_status: Charges::CostStatus::UNKNOWN,
    pricing_basis: "provider_usage",
    provider_field: item["provider_field"] || "response.output.#{item['type']}",
    provider_item_id: provider_item_id,
    details: line_item_details(item)
  )
end

.chat_completions_search_model?(model) ⇒ Boolean

Returns:

  • (Boolean)


127
128
129
130
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 127

def chat_completions_search_model?(model)
  name = local_model_name(model)
  name && ModelFamilies.chat_completions_search?(name)
end

.chat_completions_search_provider_field(choices, model) ⇒ Object



43
44
45
46
47
48
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 43

def chat_completions_search_provider_field(choices, model)
  return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
  return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)

  nil
end

.chat_completions_used_web_search?(choices) ⇒ Boolean

Returns:

  • (Boolean)


50
51
52
53
54
55
56
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 50

def chat_completions_used_web_search?(choices)
  Array(choices).any? do |choice|
    Array(choice.dig("message", "annotations")).any? do |annotation|
      annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
    end
  end
end

.chat_completions_web_search_items(response, model: nil) ⇒ Object



33
34
35
36
37
38
39
40
41
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 33

def chat_completions_web_search_items(response, model: nil)
  return [] unless response["choices"]

  provider_field = chat_completions_search_provider_field(response["choices"], model)
  return [] unless provider_field

  [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
     "provider_field" => provider_field }]
end

.dimension_key_for(item, request:, model:) ⇒ Object



105
106
107
108
109
110
111
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 105

def dimension_key_for(item, request:, model:)
  dimension = output_dimension(item["type"])
  return dimension unless dimension == "web_search_request"
  return dimension unless web_search_preview_used?(request) || chat_completions_search_model?(model)

  reasoning_model?(model) ? "web_search_preview_request_reasoning" : "web_search_preview_request_non_reasoning"
end

.line_item_details(item) ⇒ Object



143
144
145
146
147
148
149
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 143

def line_item_details(item)
  {
    status: item["status"],
    action_type: item.dig("action", "type"),
    container_id: item["container_id"]
  }.compact
end

.line_items_from_output(output_items, request: nil, model: nil) ⇒ Object



16
17
18
19
20
21
22
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 16

def line_items_from_output(output_items, request: nil, model: nil)
  deduped = {}
  Array(output_items).each { |item| store_output_item(deduped, item) }
  deduped.values
         .select { |item| billable?(item) }
         .filter_map { |item| build_line_item(item, request: request, model: model) }
end

.local_model_name(model) ⇒ Object



137
138
139
140
141
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 137

def local_model_name(model)
  return nil unless model

  model.to_s.split("/", 2).last
end

.openai_stream_service_line_items(events, request: nil, model: nil) ⇒ Object



151
152
153
154
155
156
157
158
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 151

def openai_stream_service_line_items(events, request: nil, model: nil)
  output_items = []
  each_event_data(events) do |data|
    output_items.concat(Array(data.dig("response", "output")))
    output_items << data["item"] if data["item"]
  end
  line_items_from_output(output_items, request: request, model: model)
end

.output_dimension(type) ⇒ Object



113
114
115
116
117
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 113

def output_dimension(type)
  key = RESPONSE_OUTPUT_RENAMES[type] || type
  dimension = Usage::Catalog[key]
  key if dimension && dimension.token_key.nil?
end

.reasoning_model?(model) ⇒ Boolean

Returns:

  • (Boolean)


132
133
134
135
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 132

def reasoning_model?(model)
  name = local_model_name(model)
  name && ModelFamilies.reasoning?(name)
end

.service_line_items_for(response, request: nil, model: nil) ⇒ Object



24
25
26
27
28
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 24

def service_line_items_for(response, request: nil, model: nil)
  output_items = Array(response["output"])
  output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
  line_items_from_output(output_items, request: request, model: model)
end

.store_output_item(output_items, item) ⇒ Object



69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 69

def store_output_item(output_items, item)
  return unless item.is_a?(Hash)

  dimension = output_dimension(item["type"])
  return unless dimension

  key = if dimension == "container_session" && item["container_id"]
          "#{dimension}:#{item['container_id']}"
        else
          item["id"] || "#{item['type']}:#{output_items.length}"
        end
  output_items[key] = item
end

.transcription_line_items(usage) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 160

def transcription_line_items(usage)
  return [] unless usage

  type = (usage[:type] || usage["type"]).to_s
  return [] unless type == "duration"

  seconds = (usage[:seconds] || usage["seconds"]).to_f
  return [] unless seconds.positive?

  [Charges::LineItem.build(
    dimension_key: "transcription_minute",
    quantity: (seconds / 60.0).ceil,
    cost_status: Charges::CostStatus::UNKNOWN,
    pricing_basis: "provider_usage",
    provider_field: "usage.seconds",
    details: { seconds: seconds }
  )]
end

.web_search_preview_used?(request) ⇒ Boolean

Returns:

  • (Boolean)


119
120
121
122
123
124
125
# File 'lib/llm_cost_tracker/providers/openai/service_charges.rb', line 119

def web_search_preview_used?(request)
  tools = request && (request[:tools] || request["tools"])
  Array(tools).any? do |tool|
    type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
    type.to_s.include?("web_search_preview")
  end
end