Module: LlmCostTracker::Reconciliation::Sources::OpenaiUsage

Defined in:
lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb

Constant Summary collapse

FINGERPRINT_KEYS =
%i[start_time end_time line_item model project_id api_key_id organization_id].freeze
ROW_TYPE_COST =
"cost"
AUTHORITY_COST_API =
"cost_api"
DEFAULT_METER =
"tokens"

Class Method Summary collapse

Class Method Details

.coerce_hash(response) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 124

def coerce_hash(response)
  return {} if response.nil?
  return symbolize(response) if response.is_a?(Hash)

  parsed = JSON.parse(response.to_s)
  raise ArgumentError, "OpenAI Costs payload must be a JSON object" unless parsed.is_a?(Hash)

  symbolize(parsed)
rescue JSON::ParserError => e
  raise ArgumentError, "Unable to parse OpenAI Costs payload: #{e.message}"
end

.end_inclusive_date(value) ⇒ Object



115
116
117
118
119
120
121
122
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 115

def end_inclusive_date(value)
  time = if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
           Time.at(Integer(value)).utc
         else
           Time.parse(value.to_s).utc
         end
  (time - 1).utc.to_date
end

.epoch_to_date(value) ⇒ Object



109
110
111
112
113
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 109

def epoch_to_date(value)
  return Time.at(Integer(value)).utc.to_date if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)

  Time.parse(value.to_s).utc.to_date
end

.fingerprint_for(result, start_time:, end_time:) ⇒ Object



95
96
97
98
99
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 95

def fingerprint_for(result, start_time:, end_time:)
  attributes = result.merge(start_time: normalized_epoch(start_time),
                            end_time: normalized_epoch(end_time))
  Fingerprint.compute(FINGERPRINT_KEYS, attributes)
end

.match_basis_for(result) ⇒ Object



87
88
89
90
91
92
93
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 87

def match_basis_for(result)
  return "project" if result[:project_id]
  return "api_key" if result[:api_key_id]
  return "model" if result[:model]

  "period_only"
end

.metadata_for(result, authority:, row_type:) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 63

def (result, authority:, row_type:)
  {
    "row_type" => row_type,
    "meter" => meter_for(result),
    "authority" => authority,
    "match_basis" => match_basis_for(result),
    "line_item" => result[:line_item],
    "model" => result[:model],
    "provider_project_id" => result[:project_id],
    "provider_api_key_id" => result[:api_key_id],
    "provider_workspace_id" => result[:organization_id]
  }.compact
end

.meter_for(result) ⇒ Object



77
78
79
80
81
82
83
84
85
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 77

def meter_for(result)
  line_item = result[:line_item].to_s.downcase
  case line_item
  when /web search/, /search content/ then "web_search"
  when /file search/ then "file_search_storage"
  when /code interpreter/, /container/ then "container_session"
  else DEFAULT_METER
  end
end

.normalized_epoch(value) ⇒ Object



101
102
103
104
105
106
107
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 101

def normalized_epoch(value)
  return value.to_i if value.is_a?(Numeric)

  Time.parse(value.to_s).utc.to_i
rescue ArgumentError
  value.to_s
end

.parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST) ⇒ Object



19
20
21
22
23
24
25
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 19

def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
  payload = coerce_hash(response)
  buckets = Array(payload[:data])
  buckets.flat_map do |bucket|
    rows_for_bucket(bucket, authority: authority, row_type: row_type)
  end.compact
end

.row_for_result(raw, period_start:, period_end:, start_time:, end_time:, authority:, row_type:) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 46

def row_for_result(raw, period_start:, period_end:, start_time:, end_time:, authority:, row_type:)
  result = symbolize(raw)
  amount = symbolize(result[:amount] || {})
  billed_amount = amount[:value]
  return nil if billed_amount.nil?

  fingerprint = fingerprint_for(result, start_time: start_time, end_time: end_time)
  {
    external_id: "cost-#{fingerprint}",
    period_start: period_start,
    period_end: period_end,
    billed_amount: billed_amount,
    currency: (amount[:currency] || "USD").to_s.upcase,
    metadata: (result, authority: authority, row_type: row_type)
  }
end

.rows_for_bucket(bucket, authority:, row_type:) ⇒ Object



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 27

def rows_for_bucket(bucket, authority:, row_type:)
  bucket = symbolize(bucket)
  start_time = bucket[:start_time]
  end_time = bucket[:end_time]
  return [] unless start_time && end_time

  period_start = epoch_to_date(start_time)
  period_end = end_inclusive_date(end_time)

  Array(bucket[:results]).filter_map do |raw|
    row_for_result(raw,
                   period_start: period_start, period_end: period_end,
                   start_time: start_time, end_time: end_time,
                   authority: authority, row_type: row_type)
  end
rescue ArgumentError
  []
end

.symbolize(hash) ⇒ Object



136
137
138
# File 'lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb', line 136

def symbolize(hash)
  hash.to_h.transform_keys { |key| key.to_s.to_sym }
end