coolhand 0.7.0 → 0.9.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.
@@ -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,61 @@
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
+ fetch_list(query, LogSearchResult, :logs, page: page, per: per)
51
+ end
52
+
53
+ # The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
54
+ # model has no pricing.
55
+ def get_log(id, section: nil, max_chars: nil, search_query: nil, include_thinking: nil)
56
+ query = { section: section, max_chars: max_chars, search_query: search_query,
57
+ include_thinking: include_thinking }.compact
58
+ get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
59
+ end
60
+ end
61
+ end
@@ -15,24 +15,8 @@ module Coolhand
15
15
  # Helper method for forwarding webhook data to Coolhand
16
16
  def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
17
17
  # Validate required parameters
18
- unless Coolhand.required_field?(webhook_body)
19
- error_msg = "webhook_body is required and cannot be nil or empty"
20
- if Coolhand.configuration.silent
21
- puts "COOLHAND WARNING: #{error_msg}"
22
- return false
23
- else
24
- raise ArgumentError, error_msg
25
- end
26
- end
27
-
28
- unless Coolhand.required_field?(source)
29
- error_msg = "source is required and cannot be nil or empty"
30
- if Coolhand.configuration.silent
31
- puts "COOLHAND WARNING: #{error_msg}"
32
- return false
33
- else
34
- raise ArgumentError, error_msg
35
- end
18
+ { webhook_body: webhook_body, source: source }.each do |name, value|
19
+ return reject_webhook("#{name} is required and cannot be nil or empty") unless Coolhand.required_field?(value)
36
20
  end
37
21
 
38
22
  # Auto-generate required fields unless provided
@@ -55,6 +39,14 @@ module Coolhand
55
39
 
56
40
  private
57
41
 
42
+ # Silent mode warns and drops the webhook; otherwise the caller gets the error.
43
+ def reject_webhook(error_msg)
44
+ raise ArgumentError, error_msg unless Coolhand.configuration.silent
45
+
46
+ puts "COOLHAND WARNING: #{error_msg}"
47
+ false
48
+ end
49
+
58
50
  def build_webhook_url(source, event_type)
59
51
  base = "webhook://#{source}"
60
52
  event_type ? "#{base}/#{event_type}" : base
@@ -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)
@@ -48,13 +46,12 @@ module Coolhand
48
46
  Net::HTTPResponse.prepend(ResponseInterceptor)
49
47
 
50
48
  @patched = true
51
- Coolhand.log "🔗 Net::HTTP interceptor patched"
52
49
  rescue StandardError
53
50
  # Roll back this call's hold — it never actually took effect, so it must not count
54
51
  # toward the refcount or a legitimate later unpatch! would underflow against it. This
55
52
  # branch only runs when @patched was false on entry (see `next if @patched` above), so
56
53
  # forcing it back to false here is correct regardless of which line above raised —
57
- # including a failure in the log call itself, after prepend already succeeded.
54
+ # including a failure in the second prepend, after the first already succeeded.
58
55
  @patch_count -= 1
59
56
  @patched = false
60
57
  raise
@@ -70,7 +67,7 @@ module Coolhand
70
67
  next if @patch_count.positive? || !@patched
71
68
 
72
69
  @patched = false
73
- Coolhand.log "🔌 Faraday monitoring disabled ..."
70
+ Coolhand.log "🔌 Net::HTTP interceptor unpatched (capture stopped)"
74
71
  end
75
72
  end
76
73
 
@@ -93,7 +90,7 @@ module Coolhand
93
90
  active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
94
91
  return super if active.key?(self)
95
92
 
96
- url = capturable_url(req)
93
+ url = Helpers.capturable_url(self, req)
97
94
  return super unless url
98
95
 
99
96
  # Capture body before setting the guard — if this raises we skip logging cleanly
@@ -102,7 +99,7 @@ module Coolhand
102
99
  # from being attempted — this gem must never be the reason the host
103
100
  # app's actual LLM call doesn't happen.
104
101
  captured_body = begin
105
- capture_request_body(req, body)
102
+ Helpers.capture_request_body(req, body)
106
103
  rescue StandardError => e
107
104
  Coolhand.log "❌ Error capturing request body: #{e.message}"
108
105
  nil
@@ -129,9 +126,9 @@ module Coolhand
129
126
  body_content = Thread.current[:coolhand_stream_buffer] || response&.body
130
127
  body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
131
128
  status_code = response.respond_to?(:code) ? response.code.to_i : nil
132
- response_body = parse_json(body_content)
129
+ response_body = BaseInterceptor.parse_json(body_content)
133
130
  rescue StandardError => e
134
- status_code = extract_status_from_exception(e)
131
+ status_code = Helpers.extract_status_from_exception(e)
135
132
  response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
136
133
  raise
137
134
  ensure
@@ -141,13 +138,15 @@ module Coolhand
141
138
  end_time = Time.now
142
139
  duration_ms = ((end_time - start_time) * 1000).round(2)
143
140
 
144
- send_complete_request_log(
141
+ # Read here on the calling thread: the log worker thread has no access to our thread-locals.
142
+ BaseInterceptor.send_complete_request_log(
143
+ declared: DeclaredContext.current,
145
144
  request_id: request_id,
146
145
  method: req.method,
147
146
  url: url,
148
- request_headers: sanitize_headers(req),
147
+ request_headers: BaseInterceptor.sanitize_headers(req),
149
148
  request_body: captured_body,
150
- response_headers: sanitize_headers(response),
149
+ response_headers: BaseInterceptor.sanitize_headers(response),
151
150
  response_body: response_body,
152
151
  status_code: status_code,
153
152
  start_time: start_time,
@@ -160,164 +159,171 @@ module Coolhand
160
159
  response
161
160
  end
162
161
 
163
- private
164
-
165
- def should_capture?
166
- return true if Coolhand.configuration.debug_mode
162
+ # Stateless helpers for #request. They live in their own namespaced module (not on the
163
+ # prepended module itself) so the only method NetHttpInterceptor adds to Net::HTTP is
164
+ # `request` — generically named helpers like `intercept?` would otherwise pollute
165
+ # Net::HTTP and could collide with, or be shadowed by, other code.
166
+ module Helpers
167
+ module_function
167
168
 
168
- override = Thread.current[:coolhand_capture_override]
169
- return override unless override.nil?
169
+ def should_capture?
170
+ return true if Coolhand.configuration.debug_mode
170
171
 
171
- Coolhand.configuration.capture
172
- end
172
+ override = Thread.current[:coolhand_capture_override]
173
+ return override unless override.nil?
173
174
 
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)
175
+ Coolhand.configuration.capture
185
176
  end
186
- return nil if content.nil?
187
177
 
188
- cap_and_parse(content, req)
189
- end
178
+ def capture_request_body(req, body)
179
+ # Check content-type before touching body_stream at all — for a binary
180
+ # upload (multipart/form-data, audio/*, etc.) this avoids reading the
181
+ # stream into memory a second time just to build a log entry no one
182
+ # can read anyway.
183
+ return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
184
+
185
+ content = body || req.body
186
+ if content.nil? && req.respond_to?(:body_stream) && req.body_stream
187
+ content = req.body_stream.read
188
+ req.body_stream = StringIO.new(content)
189
+ end
190
+ return nil if content.nil?
190
191
 
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
192
+ cap_and_parse(content, req)
193
+ end
195
194
 
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)
195
+ def binary_upload?(req)
196
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
197
+ content_type && !content_type.match?(/json/i)
200
198
  end
201
199
 
202
- parse_json(content)
203
- end
200
+ def cap_and_parse(content, req)
201
+ max_bytes = Coolhand.configuration.max_captured_body_bytes
202
+ if max_bytes && content.bytesize > max_bytes
203
+ return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
204
+ end
204
205
 
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
206
+ BaseInterceptor.parse_json(content)
207
+ end
211
208
 
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)
209
+ def skipped_capture_marker(req, reason, extra = {})
210
+ marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
211
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
212
+ marker["content_type"] = content_type if content_type
213
+ marker
214
+ end
215
215
 
216
- match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
217
- match ? match[1].to_i : nil
218
- end
216
+ def extract_status_from_exception(e)
217
+ return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
218
+ return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
219
219
 
220
- def intercept?(url)
221
- return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
220
+ match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
221
+ match ? match[1].to_i : nil
222
+ end
222
223
 
223
- uri = safe_parse(url)
224
- return false unless uri&.host
224
+ def intercept?(url)
225
+ return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
225
226
 
226
- return false if excluded_by_pattern?(uri)
227
+ uri = safe_parse(url)
228
+ return false unless uri&.host
227
229
 
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) } }
230
+ return false if excluded_by_pattern?(uri)
233
231
 
234
- return false unless google_api_host_configured?(addresses)
235
- return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
232
+ host = uri.host.downcase.chomp(".")
233
+ path = uri.path.to_s
234
+ addresses = Coolhand.configuration.intercept_addresses
235
+ authorities = [host, "#{host}:#{uri.port}"]
236
+ return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
236
237
 
237
- Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
238
- end
238
+ return false unless google_api_host_configured?(addresses)
239
+ return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
239
240
 
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
241
+ Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
242
+ end
249
243
 
250
- def excluded_by_pattern?(uri)
251
- patterns = Coolhand.configuration.exclude_api_patterns
252
- return false if patterns.nil? || patterns.empty?
244
+ # The capture decision runs before the host's real request, so a bad config value (e.g. a
245
+ # non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
246
+ def capturable_url(http, req)
247
+ url = build_url_for_request(http, req)
248
+ url if intercept?(url) && should_capture?
249
+ rescue StandardError => e
250
+ Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
251
+ nil
252
+ end
253
+
254
+ def excluded_by_pattern?(uri)
255
+ patterns = Coolhand.configuration.exclude_api_patterns
256
+ return false if patterns.nil? || patterns.empty?
253
257
 
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}\")"
258
+ path = uri.path.to_s
259
+ matched = patterns.find { |pattern| path.include?(pattern) }
260
+ if matched && Coolhand.configuration.debug_mode
261
+ Coolhand.log "🚫 Skipping capture for #{BaseInterceptor.sanitize_url(uri.to_s)} " \
262
+ "(matched exclude_api_pattern: \"#{matched}\")"
263
+ end
264
+ !!matched
258
265
  end
259
- !!matched
260
- end
261
266
 
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")
267
+ # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
268
+ # user still wants Google API traffic intercepted at all — otherwise overriding
269
+ # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
270
+ def google_api_host_configured?(addresses)
271
+ addresses.any? do |a|
272
+ a = a.to_s.downcase
273
+ a == "googleapis.com" || a.end_with?(".googleapis.com")
274
+ end
269
275
  end
270
- end
271
276
 
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
277
+ # An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
278
+ # path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
279
+ # that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
280
+ # matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
281
+ # host aren't swept in alongside the ones we mean to capture.
282
+ # The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
283
+ # stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
284
+ # a same-prefix-but-different-segment path like "/openaiz".
285
+ def address_matches?(host, path, pattern)
286
+ host_pattern, sep, path_pattern = pattern.to_s.partition("/")
287
+ return host_matches?(host, host_pattern) if sep.empty?
288
+ return false unless host_matches?(host, host_pattern)
289
+
290
+ path_pattern = path_pattern.delete_suffix("/")
291
+ return true if path_pattern.empty?
292
+
293
+ prefix = "/#{path_pattern}"
294
+ path == prefix || path.start_with?("#{prefix}/")
295
+ end
291
296
 
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?("*")
297
+ # Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
298
+ # A single "*" in `pattern` matches exactly one host label, e.g.
299
+ # "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
300
+ def host_matches?(host, pattern)
301
+ pattern = pattern.to_s.downcase
302
+ return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
298
303
 
299
- regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
300
- !!(host =~ regex)
301
- end
304
+ regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
305
+ !!(host =~ regex)
306
+ end
302
307
 
303
- def safe_parse(url)
304
- URI.parse(url)
305
- rescue URI::InvalidURIError
306
- nil
307
- end
308
+ def safe_parse(url)
309
+ URI.parse(url)
310
+ rescue URI::InvalidURIError
311
+ nil
312
+ end
308
313
 
309
- def build_url_for_request(http, req)
310
- return req.path if %r{\Ahttps?://}.match?(req.path)
314
+ def build_url_for_request(http, req)
315
+ return req.path if %r{\Ahttps?://}.match?(req.path)
311
316
 
312
- scheme = http.use_ssl? ? "https" : "http"
313
- host = http.address
314
- port = http.port
315
- default = http.use_ssl? ? 443 : 80
317
+ scheme = http.use_ssl? ? "https" : "http"
318
+ host = http.address
319
+ port = http.port
320
+ default = http.use_ssl? ? 443 : 80
316
321
 
317
- url = "#{scheme}://#{host}"
318
- url << ":#{port}" if port != default
319
- url << req.path
320
- url
322
+ url = "#{scheme}://#{host}"
323
+ url << ":#{port}" if port != default
324
+ url << req.path
325
+ url
326
+ end
321
327
  end
322
328
  end
323
329
  end