Module: Coolhand::BaseInterceptor

Included in:
NetHttpInterceptor
Defined in:
lib/coolhand/base_interceptor.rb

Overview

Base module with common functionality for all interceptors

Class Method Summary collapse

Class Method Details

.clean_request_headers(headers) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
# File 'lib/coolhand/base_interceptor.rb', line 78

def clean_request_headers(headers)
  cleaned = headers.dup

  # Remove sensitive headers
  cleaned.delete("Authorization")
  cleaned.delete("authorization")
  cleaned.delete("x-api-key")
  cleaned.delete("X-API-Key")

  cleaned
end

.clean_response_headers(headers) ⇒ Object



90
91
92
93
94
# File 'lib/coolhand/base_interceptor.rb', line 90

def clean_response_headers(headers)
  # Response headers typically don't contain sensitive data
  # but we can filter if needed
  headers.dup
end

.extract_response_data(response) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/coolhand/base_interceptor.rb', line 8

def extract_response_data(response)
  case response
  when Hash
    response
  when Struct
    response.to_h
  else
    # Handle streaming responses - these are often enumerator objects
    # that can't be serialized directly
    if response.class.name.include?("Stream") || response.respond_to?(:each)
      {
        response_type: "streaming",
        class: response.class.name,
        note: "Streaming response - content captured during enumeration"
      }
    elsif response.respond_to?(:to_h)
      begin
        response.to_h
      rescue StandardError => e
        {
          serialization_error: e.message,
          class: response.class.name,
          raw_response: response.to_s
        }
      end
    else
      # Extract content and token usage information
      response_data = {}

      # Get content
      response_data[:content] = response.content if response.respond_to?(:content)

      # Extract token usage information
      response_data[:usage] = (response.usage) if response.respond_to?(:usage)

      # Extract model information
      response_data[:model] = response.model if response.respond_to?(:model)

      # Extract role information
      response_data[:role] = response.role if response.respond_to?(:role)

      # Extract ID if available
      response_data[:id] = response.id if response.respond_to?(:id)

      # Extract stop reason if available
      response_data[:stop_reason] = response.stop_reason if response.respond_to?(:stop_reason)

      # Add class info for debugging
      response_data[:class] = response.class.name

      response_data.empty? ? { raw_response: response.to_s, class: response.class.name } : response_data
    end
  end
end

.extract_usage_metadata(usage) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/coolhand/base_interceptor.rb', line 63

def (usage)
  if usage.respond_to?(:to_h)
    usage.to_h
  elsif usage.is_a?(Hash)
    usage
  else
    # Extract individual usage fields
    usage_data = {}
    usage_data[:input_tokens] = usage.input_tokens if usage.respond_to?(:input_tokens)
    usage_data[:output_tokens] = usage.output_tokens if usage.respond_to?(:output_tokens)
    usage_data[:total_tokens] = usage_data[:input_tokens].to_i + usage_data[:output_tokens].to_i
    usage_data
  end
end

.normalize_header_value(value) ⇒ Object

Helper: convert arrays -> joined string, otherwise to_s



146
147
148
149
150
151
152
# File 'lib/coolhand/base_interceptor.rb', line 146

def normalize_header_value(value)
  if value.is_a?(Array)
    value.join(", ")
  else
    value.to_s
  end
end

.parse_json(string) ⇒ Object



207
208
209
210
211
# File 'lib/coolhand/base_interceptor.rb', line 207

def parse_json(string)
  JSON.parse(string)
rescue JSON::ParserError, TypeError
  string
end

.sanitize_headers(headers) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/coolhand/base_interceptor.rb', line 96

def sanitize_headers(headers)
  return {} if headers.nil?

  # Normalize various header-like objects into a Hash{String => String}
  raw = if headers.is_a?(Hash)
    headers.transform_keys(&:to_s).transform_values { |v| normalize_header_value(v) }
  elsif headers.respond_to?(:to_hash)
    begin
      headers.to_hash.transform_keys(&:to_s).transform_values { |v| normalize_header_value(v) }
    rescue StandardError
      # fall through to other enumeration strategies
      nil
    end
  elsif headers.respond_to?(:each_header)
    h = {}
    headers.each_header { |k, v| h[k.to_s] = normalize_header_value(v) }
    h
  elsif headers.respond_to?(:each)
    h = {}
    headers.each { |k, v| h[k.to_s] = normalize_header_value(v) }
    h
  else
    { "raw" => headers.to_s }
  end

  raw ||= {} # in case to_hash raised and nothing was built

  sanitized = raw.dup

  sanitized_keys = %w[openai-api-key api-key x-api-key x-goog-api-key]
  sanitized.each do |k, v|
    next if v.nil?

    key_down = k.to_s.downcase

    if key_down == "authorization"
      sanitized[k] = if v.to_s.match?(/\ABearer\s+/i)
        v.to_s.gsub(/\ABearer\s+.+/i, "Bearer [REDACTED]")
      else
        "[REDACTED]"
      end
    elsif sanitized_keys.include?(key_down)
      sanitized[k] = "[REDACTED]"
    end
  end

  sanitized
end

.sanitize_url(url) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/coolhand/base_interceptor.rb', line 154

def sanitize_url(url)
  uri = URI.parse(url)
  return url unless uri.query

  sensitive = %w[key api_key apikey token access_token secret]
  params = URI.decode_www_form(uri.query)
  redacted = false
  params.map! do |n, v|
    if sensitive.include?(n.downcase)
      redacted = true
      [n, "[REDACTED]"]
    else
      [n, v]
    end
  end

  if redacted
    uri.query = URI.encode_www_form(params)
    uri.to_s
  else
    url
  end
rescue URI::InvalidURIError
  url
end

.send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:, response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:) ⇒ Object



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/coolhand/base_interceptor.rb', line 180

def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
  response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:)
  request_data = {
    raw_request: {
      id: request_id,
      timestamp: start_time.iso8601,
      method: method.to_s.downcase,
      url: sanitize_url(url),
      headers: request_headers,
      request_body: request_body,
      response_headers: response_headers,
      response_body: response_body,
      status_code: status_code,
      duration_ms: duration_ms,
      completed_at: end_time.iso8601,
      is_streaming: is_streaming
    }
  }

  api_service = Coolhand::ApiService.new
  api_service.send_llm_request_log(request_data)

  Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
rescue StandardError => e
  Coolhand.log "❌ Error sending complete request log: #{e.message}"
end