coolhand 0.7.0 → 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.
@@ -169,10 +169,10 @@ module Coolhand
169
169
 
170
170
  request_data = { raw_request: raw_request }
171
171
 
172
- api_service = Coolhand::ApiService.new
173
- api_service.send_llm_request_log(request_data)
174
-
175
- Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
172
+ LogQueue.submit do
173
+ Coolhand::ApiService.new.send_llm_request_log(request_data)
174
+ Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
175
+ end
176
176
  rescue StandardError => e
177
177
  Coolhand.log "❌ Error sending complete request log: #{e.message}"
178
178
  end
@@ -29,7 +29,7 @@ module Coolhand
29
29
  DEFAULT_MAX_CAPTURED_BODY_BYTES = 1_000_000
30
30
 
31
31
  attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled,
32
- :max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store
32
+ :max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store, :async_logging
33
33
  attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
34
34
 
35
35
  def initialize
@@ -44,6 +44,7 @@ module Coolhand
44
44
  @capture = true
45
45
  @exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
46
46
  @enabled = true
47
+ @async_logging = true
47
48
  @max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
48
49
  @webhook_replay_tolerance_seconds = 300
49
50
  @webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
@@ -3,7 +3,8 @@
3
3
  module Coolhand
4
4
  class Error < StandardError; end
5
5
 
6
- # Raised by the gem's read methods when the Coolhand API answers with a non-2xx status.
6
+ # Raised by the gem's raising API methods (template reads, feedback links) when the Coolhand API
7
+ # answers with a non-2xx status.
7
8
  #
8
9
  # `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
9
10
  # message text.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Runs log-shipping work on background threads so a slow or unreachable Coolhand backend can't
5
+ # add latency to the host app's own LLM calls.
6
+ #
7
+ # Work is spread over two lanes, each with its own bounded queue and worker thread: `:default`
8
+ # for the one-log-per-intercepted-call sends, and `:batch` for whole-batch jobs (download +
9
+ # one send per item) that can run for minutes. Keeping them apart stops a long batch from
10
+ # delaying — or, once the queue fills, evicting — the real-time logs.
11
+ #
12
+ # Each queue is bounded (oldest job dropped when full), restarts itself in a forked child, and
13
+ # is flushed at process exit. Work runs inline instead when `config.async_logging` is false,
14
+ # when `config.debug_mode` is on (debug output should appear in order), or when the caller is
15
+ # already a worker thread (a queued job's own sends don't re-enter a queue).
16
+ module LogQueue
17
+ MAX_QUEUE_SIZE = 1000
18
+ DEFAULT_FLUSH_TIMEOUT = 5
19
+
20
+ # One bounded FIFO plus the thread that drains it.
21
+ class Lane
22
+ attr_reader :thread
23
+
24
+ def initialize
25
+ @mutex = Mutex.new
26
+ @wakeup = ConditionVariable.new
27
+ @idle = ConditionVariable.new
28
+ @jobs = []
29
+ @thread = nil
30
+ @pid = Process.pid
31
+ @busy = false
32
+ end
33
+
34
+ def enqueue(job)
35
+ @mutex.synchronize do
36
+ restart_after_fork
37
+ drop_oldest if @jobs.size >= MAX_QUEUE_SIZE
38
+ @jobs << job
39
+ ensure_worker
40
+ @wakeup.signal
41
+ end
42
+ end
43
+
44
+ # True once every queued job has finished, false if the timeout elapsed first.
45
+ def flush(deadline)
46
+ @mutex.synchronize do
47
+ # A forked child's copy of the queue belongs to the parent's worker; nothing to wait on.
48
+ return true if @pid != Process.pid
49
+ # A dead worker will never drain the queue, so don't wait on it.
50
+ return @jobs.empty? unless @thread&.alive?
51
+
52
+ while @jobs.any? || @busy
53
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ return false unless remaining.positive?
55
+
56
+ @idle.wait(@mutex, remaining)
57
+ end
58
+ true
59
+ end
60
+ end
61
+
62
+ def reset!
63
+ thread = nil
64
+ @mutex.synchronize do
65
+ thread = @thread
66
+ @jobs.clear
67
+ @thread = nil
68
+ @busy = false
69
+ @wakeup.broadcast
70
+ @idle.broadcast
71
+ end
72
+ thread&.join(1)
73
+ thread&.kill if thread&.alive?
74
+ end
75
+
76
+ private
77
+
78
+ def drop_oldest
79
+ @jobs.shift
80
+ Coolhand.log "⚠️ Log queue full (#{MAX_QUEUE_SIZE}); dropping the oldest pending log"
81
+ end
82
+
83
+ # A forked child inherits the queue's memory but not its worker thread.
84
+ def restart_after_fork
85
+ return if @pid == Process.pid
86
+
87
+ @pid = Process.pid
88
+ @jobs = []
89
+ @thread = nil
90
+ @busy = false
91
+ end
92
+
93
+ def ensure_worker
94
+ return if @thread&.alive?
95
+
96
+ @thread = Thread.new { work }
97
+ @thread.report_on_exception = false
98
+ end
99
+
100
+ def work
101
+ loop do
102
+ job = next_job
103
+ break unless job
104
+
105
+ begin
106
+ run(job)
107
+ ensure
108
+ @mutex.synchronize do
109
+ @busy = false
110
+ @idle.broadcast
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ def next_job
117
+ @mutex.synchronize do
118
+ @wakeup.wait(@mutex) while @jobs.empty? && @thread == Thread.current
119
+ return nil unless @thread == Thread.current
120
+
121
+ @busy = true
122
+ @jobs.shift
123
+ end
124
+ end
125
+
126
+ def run(job)
127
+ job.call
128
+ rescue StandardError => e
129
+ Coolhand.log "❌ Error in background log job: #{e.message}"
130
+ end
131
+ end
132
+
133
+ LANES = { default: Lane.new, batch: Lane.new }.freeze
134
+
135
+ @at_exit_mutex = Mutex.new
136
+ @at_exit_registered = false
137
+
138
+ class << self
139
+ def submit(lane: :default, &job)
140
+ return yield unless async?
141
+
142
+ register_at_exit
143
+ LANES.fetch(lane).enqueue(job)
144
+ nil
145
+ end
146
+
147
+ # Blocks until every queued job has finished or the timeout elapses. Returns true when
148
+ # every lane drained in time.
149
+ def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
150
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
151
+ LANES.values.map { |lane| lane.flush(deadline) }.all?
152
+ end
153
+
154
+ # Testing-only: discard queued work and stop the workers.
155
+ def reset!
156
+ LANES.each_value(&:reset!)
157
+ end
158
+
159
+ private
160
+
161
+ def async?
162
+ Coolhand.configuration.async_logging && !Coolhand.configuration.debug_mode && !worker_thread?
163
+ end
164
+
165
+ def worker_thread?
166
+ LANES.each_value.any? { |lane| lane.thread == Thread.current }
167
+ end
168
+
169
+ def register_at_exit
170
+ @at_exit_mutex.synchronize do
171
+ return if @at_exit_registered
172
+
173
+ @at_exit_registered = true
174
+ at_exit { flush }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "pagination"
5
+ require_relative "read_query"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ LogSearchResult = Struct.new(:logs, :pagination, keyword_init: true)
10
+
11
+ # Reads logs back out of Coolhand. The write side is {LoggerService}. Requires the client's
12
+ # **private** key. See docs/log-search.md.
13
+ class LogService < ApiService
14
+ include ReadQuery
15
+
16
+ ERROR_NOUN = "Log"
17
+ BLANK_ID_MESSAGE = "get_log: id must be a non-empty log hashid"
18
+
19
+ def initialize
20
+ super("v2/llm_request_logs")
21
+ end
22
+
23
+ # Filters, cost ordering, error semantics and server behaviour: docs/log-search.md.
24
+ def search_logs(template_id: nil, workload_id: nil, system_prompt_contains: nil, user_prompt_contains: nil,
25
+ model: nil, source_api: nil, source_api_result: nil, source_application: nil, project_path: nil,
26
+ unmatched_only: nil, days_back: nil, since: nil, until: nil, min_cost: nil, order: nil,
27
+ include_prompts: nil, include_total: nil, sort: nil, page: nil, per: nil)
28
+ query = {
29
+ template_id: template_id,
30
+ workload_id: workload_id,
31
+ system_prompt_contains: system_prompt_contains,
32
+ user_prompt_contains: user_prompt_contains,
33
+ model: model,
34
+ source_api: source_api,
35
+ source_api_result: source_api_result,
36
+ source_application: source_application,
37
+ project_path: project_path,
38
+ unmatched_only: unmatched_only,
39
+ days_back: days_back,
40
+ **window_params(since, binding.local_variable_get(:until)),
41
+ min_cost: min_cost,
42
+ order: order,
43
+ include_prompts: include_prompts,
44
+ include_total: include_total,
45
+ "q[s]": sort,
46
+ page: page,
47
+ per: per
48
+ }.compact
49
+
50
+ logs, response = get_json_with_headers(list_url(query), ERROR_NOUN)
51
+ raise Error, "#{ERROR_NOUN} response was not a JSON array" unless logs.is_a?(Array)
52
+
53
+ LogSearchResult.new(
54
+ logs: logs,
55
+ pagination: Pagination.from_headers(response, items: logs, page: page, per: per)
56
+ ).freeze
57
+ end
58
+
59
+ # The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
60
+ # model has no pricing.
61
+ def get_log(id, section: nil, max_chars: nil, search_query: nil, include_thinking: nil)
62
+ query = { section: section, max_chars: max_chars, search_query: search_query,
63
+ include_thinking: include_thinking }.compact
64
+ get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
65
+ end
66
+ end
67
+ end
@@ -4,8 +4,6 @@ require "stringio"
4
4
 
5
5
  module Coolhand
6
6
  module NetHttpInterceptor
7
- include BaseInterceptor
8
-
9
7
  # Response streaming interceptor nested under NetHttpInterceptor
10
8
  module ResponseInterceptor
11
9
  def read_body(dest = nil, &block)
@@ -70,7 +68,7 @@ module Coolhand
70
68
  next if @patch_count.positive? || !@patched
71
69
 
72
70
  @patched = false
73
- Coolhand.log "🔌 Faraday monitoring disabled ..."
71
+ Coolhand.log "🔌 Net::HTTP interceptor unpatched (capture stopped)"
74
72
  end
75
73
  end
76
74
 
@@ -93,7 +91,7 @@ module Coolhand
93
91
  active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
94
92
  return super if active.key?(self)
95
93
 
96
- url = capturable_url(req)
94
+ url = Helpers.capturable_url(self, req)
97
95
  return super unless url
98
96
 
99
97
  # Capture body before setting the guard — if this raises we skip logging cleanly
@@ -102,7 +100,7 @@ module Coolhand
102
100
  # from being attempted — this gem must never be the reason the host
103
101
  # app's actual LLM call doesn't happen.
104
102
  captured_body = begin
105
- capture_request_body(req, body)
103
+ Helpers.capture_request_body(req, body)
106
104
  rescue StandardError => e
107
105
  Coolhand.log "❌ Error capturing request body: #{e.message}"
108
106
  nil
@@ -129,9 +127,9 @@ module Coolhand
129
127
  body_content = Thread.current[:coolhand_stream_buffer] || response&.body
130
128
  body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
131
129
  status_code = response.respond_to?(:code) ? response.code.to_i : nil
132
- response_body = parse_json(body_content)
130
+ response_body = BaseInterceptor.parse_json(body_content)
133
131
  rescue StandardError => e
134
- status_code = extract_status_from_exception(e)
132
+ status_code = Helpers.extract_status_from_exception(e)
135
133
  response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
136
134
  raise
137
135
  ensure
@@ -141,13 +139,13 @@ module Coolhand
141
139
  end_time = Time.now
142
140
  duration_ms = ((end_time - start_time) * 1000).round(2)
143
141
 
144
- send_complete_request_log(
142
+ BaseInterceptor.send_complete_request_log(
145
143
  request_id: request_id,
146
144
  method: req.method,
147
145
  url: url,
148
- request_headers: sanitize_headers(req),
146
+ request_headers: BaseInterceptor.sanitize_headers(req),
149
147
  request_body: captured_body,
150
- response_headers: sanitize_headers(response),
148
+ response_headers: BaseInterceptor.sanitize_headers(response),
151
149
  response_body: response_body,
152
150
  status_code: status_code,
153
151
  start_time: start_time,
@@ -160,164 +158,171 @@ module Coolhand
160
158
  response
161
159
  end
162
160
 
163
- private
164
-
165
- def should_capture?
166
- return true if Coolhand.configuration.debug_mode
161
+ # Stateless helpers for #request. They live in their own namespaced module (not on the
162
+ # prepended module itself) so the only method NetHttpInterceptor adds to Net::HTTP is
163
+ # `request` — generically named helpers like `intercept?` would otherwise pollute
164
+ # Net::HTTP and could collide with, or be shadowed by, other code.
165
+ module Helpers
166
+ module_function
167
167
 
168
- override = Thread.current[:coolhand_capture_override]
169
- return override unless override.nil?
168
+ def should_capture?
169
+ return true if Coolhand.configuration.debug_mode
170
170
 
171
- Coolhand.configuration.capture
172
- end
171
+ override = Thread.current[:coolhand_capture_override]
172
+ return override unless override.nil?
173
173
 
174
- def capture_request_body(req, body)
175
- # Check content-type before touching body_stream at all — for a binary
176
- # upload (multipart/form-data, audio/*, etc.) this avoids reading the
177
- # stream into memory a second time just to build a log entry no one
178
- # can read anyway.
179
- return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
180
-
181
- content = body || req.body
182
- if content.nil? && req.respond_to?(:body_stream) && req.body_stream
183
- content = req.body_stream.read
184
- req.body_stream = StringIO.new(content)
174
+ Coolhand.configuration.capture
185
175
  end
186
- return nil if content.nil?
187
176
 
188
- cap_and_parse(content, req)
189
- end
177
+ def capture_request_body(req, body)
178
+ # Check content-type before touching body_stream at all — for a binary
179
+ # upload (multipart/form-data, audio/*, etc.) this avoids reading the
180
+ # stream into memory a second time just to build a log entry no one
181
+ # can read anyway.
182
+ return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
183
+
184
+ content = body || req.body
185
+ if content.nil? && req.respond_to?(:body_stream) && req.body_stream
186
+ content = req.body_stream.read
187
+ req.body_stream = StringIO.new(content)
188
+ end
189
+ return nil if content.nil?
190
190
 
191
- def binary_upload?(req)
192
- content_type = req.respond_to?(:content_type) ? req.content_type : nil
193
- content_type && !content_type.match?(/json/i)
194
- end
191
+ cap_and_parse(content, req)
192
+ end
195
193
 
196
- def cap_and_parse(content, req)
197
- max_bytes = Coolhand.configuration.max_captured_body_bytes
198
- if max_bytes && content.bytesize > max_bytes
199
- return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
194
+ def binary_upload?(req)
195
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
196
+ content_type && !content_type.match?(/json/i)
200
197
  end
201
198
 
202
- parse_json(content)
203
- end
199
+ def cap_and_parse(content, req)
200
+ max_bytes = Coolhand.configuration.max_captured_body_bytes
201
+ if max_bytes && content.bytesize > max_bytes
202
+ return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
203
+ end
204
204
 
205
- def skipped_capture_marker(req, reason, extra = {})
206
- marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
207
- content_type = req.respond_to?(:content_type) ? req.content_type : nil
208
- marker["content_type"] = content_type if content_type
209
- marker
210
- end
205
+ BaseInterceptor.parse_json(content)
206
+ end
211
207
 
212
- def extract_status_from_exception(e)
213
- return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
214
- return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
208
+ def skipped_capture_marker(req, reason, extra = {})
209
+ marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
210
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
211
+ marker["content_type"] = content_type if content_type
212
+ marker
213
+ end
215
214
 
216
- match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
217
- match ? match[1].to_i : nil
218
- end
215
+ def extract_status_from_exception(e)
216
+ return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
217
+ return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
219
218
 
220
- def intercept?(url)
221
- return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
219
+ match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
220
+ match ? match[1].to_i : nil
221
+ end
222
222
 
223
- uri = safe_parse(url)
224
- return false unless uri&.host
223
+ def intercept?(url)
224
+ return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
225
225
 
226
- return false if excluded_by_pattern?(uri)
226
+ uri = safe_parse(url)
227
+ return false unless uri&.host
227
228
 
228
- host = uri.host.downcase.chomp(".")
229
- path = uri.path.to_s
230
- addresses = Coolhand.configuration.intercept_addresses
231
- authorities = [host, "#{host}:#{uri.port}"]
232
- return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
229
+ return false if excluded_by_pattern?(uri)
233
230
 
234
- return false unless google_api_host_configured?(addresses)
235
- return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
231
+ host = uri.host.downcase.chomp(".")
232
+ path = uri.path.to_s
233
+ addresses = Coolhand.configuration.intercept_addresses
234
+ authorities = [host, "#{host}:#{uri.port}"]
235
+ return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
236
236
 
237
- Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
238
- end
237
+ return false unless google_api_host_configured?(addresses)
238
+ return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
239
239
 
240
- # The capture decision runs before the host's real request, so a bad config value (e.g. a
241
- # non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
242
- def capturable_url(req)
243
- url = build_url_for_request(self, req)
244
- url if intercept?(url) && should_capture?
245
- rescue StandardError => e
246
- Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
247
- nil
248
- end
240
+ Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
241
+ end
249
242
 
250
- def excluded_by_pattern?(uri)
251
- patterns = Coolhand.configuration.exclude_api_patterns
252
- return false if patterns.nil? || patterns.empty?
243
+ # The capture decision runs before the host's real request, so a bad config value (e.g. a
244
+ # non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
245
+ def capturable_url(http, req)
246
+ url = build_url_for_request(http, req)
247
+ url if intercept?(url) && should_capture?
248
+ rescue StandardError => e
249
+ Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
250
+ nil
251
+ end
252
+
253
+ def excluded_by_pattern?(uri)
254
+ patterns = Coolhand.configuration.exclude_api_patterns
255
+ return false if patterns.nil? || patterns.empty?
253
256
 
254
- path = uri.path.to_s
255
- matched = patterns.find { |pattern| path.include?(pattern) }
256
- if matched && Coolhand.configuration.debug_mode
257
- Coolhand.log "🚫 Skipping capture for #{sanitize_url(uri.to_s)} (matched exclude_api_pattern: \"#{matched}\")"
257
+ path = uri.path.to_s
258
+ matched = patterns.find { |pattern| path.include?(pattern) }
259
+ if matched && Coolhand.configuration.debug_mode
260
+ Coolhand.log "🚫 Skipping capture for #{BaseInterceptor.sanitize_url(uri.to_s)} " \
261
+ "(matched exclude_api_pattern: \"#{matched}\")"
262
+ end
263
+ !!matched
258
264
  end
259
- !!matched
260
- end
261
265
 
262
- # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
263
- # user still wants Google API traffic intercepted at all — otherwise overriding
264
- # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
265
- def google_api_host_configured?(addresses)
266
- addresses.any? do |a|
267
- a = a.to_s.downcase
268
- a == "googleapis.com" || a.end_with?(".googleapis.com")
266
+ # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
267
+ # user still wants Google API traffic intercepted at all — otherwise overriding
268
+ # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
269
+ def google_api_host_configured?(addresses)
270
+ addresses.any? do |a|
271
+ a = a.to_s.downcase
272
+ a == "googleapis.com" || a.end_with?(".googleapis.com")
273
+ end
269
274
  end
270
- end
271
275
 
272
- # An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
273
- # path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
274
- # that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
275
- # matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
276
- # host aren't swept in alongside the ones we mean to capture.
277
- # The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
278
- # stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
279
- # a same-prefix-but-different-segment path like "/openaiz".
280
- def address_matches?(host, path, pattern)
281
- host_pattern, sep, path_pattern = pattern.to_s.partition("/")
282
- return host_matches?(host, host_pattern) if sep.empty?
283
- return false unless host_matches?(host, host_pattern)
284
-
285
- path_pattern = path_pattern.delete_suffix("/")
286
- return true if path_pattern.empty?
287
-
288
- prefix = "/#{path_pattern}"
289
- path == prefix || path.start_with?("#{prefix}/")
290
- end
276
+ # An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
277
+ # path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
278
+ # that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
279
+ # matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
280
+ # host aren't swept in alongside the ones we mean to capture.
281
+ # The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
282
+ # stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
283
+ # a same-prefix-but-different-segment path like "/openaiz".
284
+ def address_matches?(host, path, pattern)
285
+ host_pattern, sep, path_pattern = pattern.to_s.partition("/")
286
+ return host_matches?(host, host_pattern) if sep.empty?
287
+ return false unless host_matches?(host, host_pattern)
288
+
289
+ path_pattern = path_pattern.delete_suffix("/")
290
+ return true if path_pattern.empty?
291
+
292
+ prefix = "/#{path_pattern}"
293
+ path == prefix || path.start_with?("#{prefix}/")
294
+ end
291
295
 
292
- # Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
293
- # A single "*" in `pattern` matches exactly one host label, e.g.
294
- # "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
295
- def host_matches?(host, pattern)
296
- pattern = pattern.to_s.downcase
297
- return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
296
+ # Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
297
+ # A single "*" in `pattern` matches exactly one host label, e.g.
298
+ # "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
299
+ def host_matches?(host, pattern)
300
+ pattern = pattern.to_s.downcase
301
+ return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
298
302
 
299
- regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
300
- !!(host =~ regex)
301
- end
303
+ regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
304
+ !!(host =~ regex)
305
+ end
302
306
 
303
- def safe_parse(url)
304
- URI.parse(url)
305
- rescue URI::InvalidURIError
306
- nil
307
- end
307
+ def safe_parse(url)
308
+ URI.parse(url)
309
+ rescue URI::InvalidURIError
310
+ nil
311
+ end
308
312
 
309
- def build_url_for_request(http, req)
310
- return req.path if %r{\Ahttps?://}.match?(req.path)
313
+ def build_url_for_request(http, req)
314
+ return req.path if %r{\Ahttps?://}.match?(req.path)
311
315
 
312
- scheme = http.use_ssl? ? "https" : "http"
313
- host = http.address
314
- port = http.port
315
- default = http.use_ssl? ? 443 : 80
316
+ scheme = http.use_ssl? ? "https" : "http"
317
+ host = http.address
318
+ port = http.port
319
+ default = http.use_ssl? ? 443 : 80
316
320
 
317
- url = "#{scheme}://#{host}"
318
- url << ":#{port}" if port != default
319
- url << req.path
320
- url
321
+ url = "#{scheme}://#{host}"
322
+ url << ":#{port}" if port != default
323
+ url << req.path
324
+ url
325
+ end
321
326
  end
322
327
  end
323
328
  end