coolhand 0.5.1 → 0.7.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.
- checksums.yaml +4 -4
- data/AGENTS.harness.md +139 -0
- data/CHANGELOG.md +58 -0
- data/README.md +40 -3
- data/docs/configuration.md +46 -0
- data/docs/openai.md +31 -0
- data/docs/template-search.md +218 -0
- data/docs/vertex.md +2 -0
- data/lib/coolhand/api_service.rb +49 -15
- data/lib/coolhand/base_interceptor.rb +64 -19
- data/lib/coolhand/configuration.rb +37 -4
- data/lib/coolhand/default_exclude_api_patterns.yml +14 -2
- data/lib/coolhand/default_intercept_addresses.yml +64 -5
- data/lib/coolhand/default_intercept_path_patterns.yml +12 -0
- data/lib/coolhand/errors.rb +19 -0
- data/lib/coolhand/logger_service.rb +1 -1
- data/lib/coolhand/net_http_interceptor.rb +160 -23
- data/lib/coolhand/open_ai/batch_result_processor.rb +1 -1
- data/lib/coolhand/open_ai/webhook_id_store.rb +45 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +46 -11
- data/lib/coolhand/pagination.rb +82 -0
- data/lib/coolhand/read_requests.rb +77 -0
- data/lib/coolhand/template_service.rb +76 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand.rb +13 -4
- metadata +10 -2
data/docs/vertex.md
CHANGED
|
@@ -6,6 +6,8 @@ For monitoring regular (non-batch) Vertex AI calls, no extra setup is required b
|
|
|
6
6
|
|
|
7
7
|
Requires Rails — `Coolhand::Vertex::BatchResultProcessor` logs via `Rails.logger` internally. `config.capture = false` and `Coolhand.without_capture` do not suppress these logs: unlike the passive Net::HTTP interceptor, calling this processor is an explicit, deliberate act, so it always sends.
|
|
8
8
|
|
|
9
|
+
> **Note:** As of May 2026, Google markets this product as "Gemini Enterprise Agent Platform" rather than "Vertex AI." The API endpoint (`aiplatform.googleapis.com`) and Ruby SDK are unchanged — it's a console/marketing rename, not an SDK rename — so `Coolhand::Vertex` naming stays accurate here. It's also still a distinct integration from the separate Gemini Developer API (`generativelanguage.googleapis.com`), which Coolhand intercepts independently.
|
|
10
|
+
|
|
9
11
|
## Usage
|
|
10
12
|
|
|
11
13
|
```ruby
|
data/lib/coolhand/api_service.rb
CHANGED
|
@@ -4,9 +4,19 @@ require "net/http"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "collector"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "read_requests"
|
|
7
9
|
|
|
8
10
|
module Coolhand
|
|
9
11
|
class ApiService
|
|
12
|
+
# The GET half of this class. See Coolhand::ReadRequests for why reads raise where writes
|
|
13
|
+
# log and return nil.
|
|
14
|
+
include ReadRequests
|
|
15
|
+
|
|
16
|
+
# Caps how much of a failed response body is interpolated into a log line or an exception
|
|
17
|
+
# message. Without it an oversized body from a proxy or gateway becomes the message.
|
|
18
|
+
ERROR_BODY_LIMIT = 2000
|
|
19
|
+
|
|
10
20
|
attr_reader :api_endpoint
|
|
11
21
|
|
|
12
22
|
def initialize(endpoint = "v2/llm_request_logs")
|
|
@@ -77,12 +87,7 @@ module Coolhand
|
|
|
77
87
|
http.read_timeout = 5
|
|
78
88
|
|
|
79
89
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
80
|
-
|
|
81
|
-
headers.each do |key, value|
|
|
82
|
-
# Ensure header values are UTF-8 encoded
|
|
83
|
-
encoded_value = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
84
|
-
request[key] = encoded_value
|
|
85
|
-
end
|
|
90
|
+
apply_headers(request, create_request_options(payload))
|
|
86
91
|
|
|
87
92
|
# Clean payload and ensure UTF-8 encoding before JSON generation
|
|
88
93
|
cleaned_payload = sanitize_payload_for_json(payload)
|
|
@@ -105,14 +110,7 @@ module Coolhand
|
|
|
105
110
|
log success_message
|
|
106
111
|
result
|
|
107
112
|
else
|
|
108
|
-
|
|
109
|
-
# Only show first part of HTML error pages
|
|
110
|
-
error_msg = if body&.include?("<!DOCTYPE html>")
|
|
111
|
-
"#{body[0..200]}... [HTML error page truncated]"
|
|
112
|
-
else
|
|
113
|
-
body
|
|
114
|
-
end
|
|
115
|
-
log "❌ Request failed: #{response.code} - #{error_msg}"
|
|
113
|
+
log "❌ Request failed: #{response.code} - #{format_error_body(response.body)}"
|
|
116
114
|
nil
|
|
117
115
|
end
|
|
118
116
|
rescue StandardError => e
|
|
@@ -210,6 +208,26 @@ module Coolhand
|
|
|
210
208
|
|
|
211
209
|
private
|
|
212
210
|
|
|
211
|
+
# Shared by the write path's failure log and the read path's raised message, so a large
|
|
212
|
+
# response body never dumps a whole document into either. An HTML error page (a proxy's 502,
|
|
213
|
+
# say) is cut short hard; anything else keeps enough to diagnose from and no more.
|
|
214
|
+
def format_error_body(body)
|
|
215
|
+
return nil if body.nil?
|
|
216
|
+
|
|
217
|
+
text = body.dup.force_encoding("UTF-8")
|
|
218
|
+
return "#{text[0..200]}... [HTML error page truncated]" if text.include?("<!DOCTYPE html>")
|
|
219
|
+
return text if text.length <= ERROR_BODY_LIMIT
|
|
220
|
+
|
|
221
|
+
"#{text[0, ERROR_BODY_LIMIT]}... [truncated]"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def apply_headers(request, headers)
|
|
225
|
+
headers.each do |key, value|
|
|
226
|
+
# Net::HTTP rejects header values that are not UTF-8.
|
|
227
|
+
request[key] = value.is_a?(String) ? value.dup.force_encoding("UTF-8") : value
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
213
231
|
def missing_api_key?
|
|
214
232
|
return false if Coolhand.required_field?(api_key)
|
|
215
233
|
|
|
@@ -281,8 +299,24 @@ module Coolhand
|
|
|
281
299
|
return if silent
|
|
282
300
|
|
|
283
301
|
puts "\n🎉 LOGGING OpenAI API Call #{@api_endpoint}"
|
|
284
|
-
|
|
302
|
+
|
|
303
|
+
if debug_mode?
|
|
304
|
+
puts captured_data
|
|
305
|
+
else
|
|
306
|
+
puts request_body_summary(captured_data)
|
|
307
|
+
end
|
|
308
|
+
|
|
285
309
|
puts "📤 Sending to: #{@api_endpoint}"
|
|
286
310
|
end
|
|
311
|
+
|
|
312
|
+
def request_body_summary(captured_data)
|
|
313
|
+
return "captured_data: (unavailable)" unless captured_data.is_a?(Hash)
|
|
314
|
+
|
|
315
|
+
id = captured_data[:id] || captured_data["id"] || "N/A"
|
|
316
|
+
body = captured_data[:request_body] || captured_data["request_body"]
|
|
317
|
+
"id: #{id}, request_body: #{body.to_json.bytesize} bytes"
|
|
318
|
+
rescue StandardError
|
|
319
|
+
"captured_data: (unavailable)"
|
|
320
|
+
end
|
|
287
321
|
end
|
|
288
322
|
end
|
|
@@ -11,7 +11,7 @@ module Coolhand
|
|
|
11
11
|
# (cookie, set-cookie), and future/unknown providers using a
|
|
12
12
|
# similarly-named header. Shared with LoggerService so the two logging
|
|
13
13
|
# paths (interceptor + webhook forwarding) stay consistent.
|
|
14
|
-
SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|
|
|
14
|
+
SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|auth|passw|credential|bearer|jwt|session|cookie/i
|
|
15
15
|
|
|
16
16
|
def sanitize_headers(headers)
|
|
17
17
|
return {} if headers.nil?
|
|
@@ -23,7 +23,9 @@ module Coolhand
|
|
|
23
23
|
begin
|
|
24
24
|
headers.to_hash.transform_keys(&:to_s).transform_values { |v| normalize_header_value(v) }
|
|
25
25
|
rescue StandardError
|
|
26
|
-
#
|
|
26
|
+
# Deliberately fails closed to an empty hash rather than trying each_header/each on
|
|
27
|
+
# this object next — an object whose own to_hash raises is untrustworthy enough that
|
|
28
|
+
# guessing at another enumeration strategy isn't worth the risk of a second failure.
|
|
27
29
|
nil
|
|
28
30
|
end
|
|
29
31
|
elsif headers.respond_to?(:each_header)
|
|
@@ -80,27 +82,70 @@ module Coolhand
|
|
|
80
82
|
|
|
81
83
|
def sanitize_url(url)
|
|
82
84
|
uri = URI.parse(url)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
modified = false
|
|
86
|
+
|
|
87
|
+
if uri.userinfo
|
|
88
|
+
# URI userinfo syntax disallows "[" / "]", so this can't reuse the [REDACTED]
|
|
89
|
+
# placeholder used elsewhere.
|
|
90
|
+
uri.userinfo = "REDACTED"
|
|
91
|
+
modified = true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if uri.query
|
|
95
|
+
params = URI.decode_www_form(uri.query)
|
|
96
|
+
redacted_query = false
|
|
97
|
+
params.map! do |n, v|
|
|
98
|
+
if n.match?(SENSITIVE_QUERY_PARAM_PATTERN)
|
|
99
|
+
redacted_query = true
|
|
100
|
+
[n, "[REDACTED]"]
|
|
101
|
+
else
|
|
102
|
+
[n, v]
|
|
103
|
+
end
|
|
93
104
|
end
|
|
105
|
+
if redacted_query
|
|
106
|
+
uri.query = URI.encode_www_form(params)
|
|
107
|
+
modified = true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
modified ? uri.to_s : url
|
|
112
|
+
rescue URI::InvalidURIError
|
|
113
|
+
# Fail closed: an unparseable URL can't be checked param-by-param, so drop everything
|
|
114
|
+
# that could carry a credential (userinfo, query, fragment) rather than pass it through.
|
|
115
|
+
url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Key names inside an Azure OpenAI "On Your Data" data_sources/dataSources entry that carry
|
|
119
|
+
# live datastore credentials in the request body, where header/query-param sanitization never
|
|
120
|
+
# looks. Matched against a separator-stripped, lowercased key name so both `connection_string`
|
|
121
|
+
# and `connectionString` (Cosmos/Mongo, embeds `AccountKey=...`) and `encoded_api_key`
|
|
122
|
+
# (Elasticsearch) are caught — not just exact or snake_case-only names.
|
|
123
|
+
SENSITIVE_BODY_KEY_PATTERN = /key|token|secret|passw|pwd|credential|bearer|jwt|connectionstring|signature/i
|
|
124
|
+
|
|
125
|
+
def sanitize_body(body)
|
|
126
|
+
return body unless body.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
sanitized = body.dup
|
|
129
|
+
sanitized.each_key do |key|
|
|
130
|
+
next unless key.to_s.delete("_-").casecmp?("datasources")
|
|
131
|
+
|
|
132
|
+
sanitized[key] = redact_sensitive_body_values(sanitized[key])
|
|
94
133
|
end
|
|
134
|
+
sanitized
|
|
135
|
+
end
|
|
95
136
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
137
|
+
def redact_sensitive_body_values(node)
|
|
138
|
+
case node
|
|
139
|
+
when Hash
|
|
140
|
+
node.each_with_object({}) do |(k, v), acc|
|
|
141
|
+
normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
|
|
142
|
+
acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? "[REDACTED]" : redact_sensitive_body_values(v)
|
|
143
|
+
end
|
|
144
|
+
when Array
|
|
145
|
+
node.map { |v| redact_sensitive_body_values(v) }
|
|
99
146
|
else
|
|
100
|
-
|
|
147
|
+
node
|
|
101
148
|
end
|
|
102
|
-
rescue URI::InvalidURIError
|
|
103
|
-
url
|
|
104
149
|
end
|
|
105
150
|
|
|
106
151
|
def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
|
|
@@ -111,7 +156,7 @@ module Coolhand
|
|
|
111
156
|
method: method.to_s.downcase,
|
|
112
157
|
url: sanitize_url(url),
|
|
113
158
|
headers: sanitize_headers(request_headers),
|
|
114
|
-
request_body: request_body,
|
|
159
|
+
request_body: sanitize_body(request_body),
|
|
115
160
|
response_headers: sanitize_headers(response_headers),
|
|
116
161
|
response_body: response_body,
|
|
117
162
|
status_code: status_code,
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "uri"
|
|
5
5
|
|
|
6
|
+
require_relative "open_ai/webhook_id_store"
|
|
7
|
+
|
|
6
8
|
module Coolhand
|
|
7
9
|
# Handles all configuration settings for the gem.
|
|
8
10
|
class Configuration
|
|
@@ -14,11 +16,21 @@ module Coolhand
|
|
|
14
16
|
File.join(__dir__, "default_intercept_addresses.yml")
|
|
15
17
|
).freeze
|
|
16
18
|
|
|
19
|
+
DEFAULT_INTERCEPT_PATH_PATTERNS = YAML.safe_load_file(
|
|
20
|
+
File.join(__dir__, "default_intercept_path_patterns.yml")
|
|
21
|
+
).freeze
|
|
22
|
+
|
|
17
23
|
BASE_URL_ERROR_MSG = "base_url must use https:// (or http://localhost / http://127.0.0.1 for local dev)"
|
|
18
24
|
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
# 1 MB — generous for real chat/completion payloads, small enough to stop
|
|
27
|
+
# a multi-MB file upload from being logged in full when its content-type
|
|
28
|
+
# happens to look JSON-ish (or is unset).
|
|
29
|
+
DEFAULT_MAX_CAPTURED_BODY_BYTES = 1_000_000
|
|
30
|
+
|
|
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
|
|
33
|
+
attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
|
|
22
34
|
|
|
23
35
|
def initialize
|
|
24
36
|
# Set defaults
|
|
@@ -26,20 +38,41 @@ module Coolhand
|
|
|
26
38
|
@api_key = nil
|
|
27
39
|
@silent = false
|
|
28
40
|
@intercept_addresses = DEFAULT_INTERCEPT_ADDRESSES.dup
|
|
41
|
+
@intercept_path_patterns = DEFAULT_INTERCEPT_PATH_PATTERNS.dup
|
|
29
42
|
self.base_url = "https://coolhandlabs.com/api"
|
|
30
43
|
@debug_mode = false
|
|
31
44
|
@capture = true
|
|
32
45
|
@exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
|
|
33
46
|
@enabled = true
|
|
47
|
+
@max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
|
|
48
|
+
@webhook_replay_tolerance_seconds = 300
|
|
49
|
+
@webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
|
|
34
50
|
end
|
|
35
51
|
|
|
36
|
-
#
|
|
52
|
+
# intercept_addresses is a required allow-list: NetHttpInterceptor#intercept? only
|
|
53
|
+
# captures a request whose URL matches an entry here, so an empty list would mean
|
|
54
|
+
# "never capture anything" (validate! deliberately rejects that). Unlike
|
|
55
|
+
# exclude_api_patterns, `= []` is not a supported way to disable it — use
|
|
56
|
+
# config.enabled = false or config.capture = false to disable capture entirely.
|
|
57
|
+
# nil/empty here is treated as "leave the current value alone" rather than cleared.
|
|
37
58
|
def intercept_addresses=(value)
|
|
38
|
-
|
|
59
|
+
if value.nil? || (value.is_a?(Array) && value.empty?)
|
|
60
|
+
Coolhand.log "⚠️ Coolhand: intercept_addresses = #{value.inspect} is ignored " \
|
|
61
|
+
"(would disable capture entirely) — keeping the current value. " \
|
|
62
|
+
"Use config.enabled = false or config.capture = false to disable capture."
|
|
63
|
+
return
|
|
64
|
+
end
|
|
39
65
|
|
|
40
66
|
@intercept_addresses = value.is_a?(Array) ? value : [value]
|
|
41
67
|
end
|
|
42
68
|
|
|
69
|
+
# Custom setter that preserves defaults when nil/empty array is provided
|
|
70
|
+
def intercept_path_patterns=(value)
|
|
71
|
+
return if value.nil? || (value.is_a?(Array) && value.empty?)
|
|
72
|
+
|
|
73
|
+
@intercept_path_patterns = value.is_a?(Array) ? value : [value]
|
|
74
|
+
end
|
|
75
|
+
|
|
43
76
|
def base_url=(value)
|
|
44
77
|
stripped = value&.sub(%r{/+\z}, "")
|
|
45
78
|
raise Error, BASE_URL_ERROR_MSG unless stripped.nil? || valid_base_url?(stripped)
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
# Coolhand default exclude API patterns
|
|
2
|
-
# These substrings are matched against request
|
|
3
|
-
#
|
|
2
|
+
# These substrings are matched against the request's path (not the full URL or
|
|
3
|
+
# query string). A request whose path matches is skipped and not forwarded as
|
|
4
|
+
# an llm_request_log, regardless of whether it would otherwise match intercept_addresses.
|
|
4
5
|
#
|
|
5
6
|
# Users can extend defaults: c.exclude_api_patterns << "/myOperationalPath/"
|
|
6
7
|
# Users can override entirely: c.exclude_api_patterns = ["/only_this/"]
|
|
7
8
|
# Users can disable: c.exclude_api_patterns = []
|
|
8
9
|
|
|
9
10
|
- "/batchPredictionJobs/"
|
|
11
|
+
# Azure OpenAI control-plane paths (not inference) — both spellings are needed
|
|
12
|
+
# since substring matching is contiguous: "/openai/files" does not match
|
|
13
|
+
# "/openai/v1/files".
|
|
14
|
+
- "/openai/files"
|
|
15
|
+
- "/openai/batches"
|
|
16
|
+
- "/openai/fine_tuning"
|
|
17
|
+
- "/openai/models"
|
|
18
|
+
- "/openai/v1/files"
|
|
19
|
+
- "/openai/v1/batches"
|
|
20
|
+
- "/openai/v1/fine_tuning"
|
|
21
|
+
- "/openai/v1/models"
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
# Coolhand default intercept addresses
|
|
2
|
-
# These
|
|
3
|
-
#
|
|
2
|
+
# These are matched against the request's parsed host (exact match, or a
|
|
3
|
+
# dot-boundary suffix match, case-insensitive) to decide whether a request
|
|
4
|
+
# should be captured and forwarded as an llm_request_log. A single "*" in an
|
|
5
|
+
# entry matches exactly one host label — e.g. "bedrock-runtime.*.amazonaws.com"
|
|
6
|
+
# matches "bedrock-runtime.us-east-1.amazonaws.com" but nothing else.
|
|
7
|
+
# An entry may also pin a port and/or a path prefix: "host:port", "host/path",
|
|
8
|
+
# or "host:port/path" (the path match is on a segment boundary) — useful for
|
|
9
|
+
# multi-service hosts where unrelated APIs (e.g. Azure Speech, Vision, Language)
|
|
10
|
+
# share a domain with the LLM inference path.
|
|
4
11
|
#
|
|
5
12
|
# Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
|
|
6
13
|
# Users can override entirely: c.intercept_addresses = ["only.this.com"]
|
|
14
|
+
# This list cannot be emptied — it's required (unlike exclude_api_patterns, which
|
|
15
|
+
# can be disabled with `= []`). To disable capture entirely, use
|
|
16
|
+
# c.enabled = false or c.capture = false instead.
|
|
7
17
|
|
|
8
18
|
- "api.openai.com"
|
|
9
19
|
- "api.anthropic.com"
|
|
@@ -11,9 +21,58 @@
|
|
|
11
21
|
- "generativelanguage.googleapis.com"
|
|
12
22
|
- "models.github.ai"
|
|
13
23
|
- "models.inference.ai.azure.com"
|
|
14
|
-
- ":generateContent"
|
|
15
|
-
- ":streamGenerateContent"
|
|
16
24
|
- "aiplatform.googleapis.com"
|
|
17
25
|
- "gateway.ai.cloudflare.com"
|
|
18
|
-
- "bedrock-runtime"
|
|
26
|
+
- "bedrock-runtime.*.amazonaws.com"
|
|
27
|
+
- "api.deepseek.com"
|
|
28
|
+
- "api.mistral.ai"
|
|
29
|
+
- "api.perplexity.ai"
|
|
30
|
+
- "api.x.ai"
|
|
31
|
+
# Cohere is path-scoped, never host-wide: the server only ingests v2 chat and
|
|
32
|
+
# v1/v2 embed, and its other endpoints (v1 chat, rerank, tokenize, classify...)
|
|
33
|
+
# have different envelopes that would be recorded as empty "successes".
|
|
34
|
+
- "api.cohere.com/v2/chat"
|
|
35
|
+
- "api.cohere.com/v1/embed"
|
|
36
|
+
- "api.cohere.com/v2/embed"
|
|
37
|
+
- "api.cohere.ai/v2/chat"
|
|
38
|
+
- "api.cohere.ai/v1/embed"
|
|
39
|
+
- "api.cohere.ai/v2/embed"
|
|
40
|
+
# TypeSafe Jev (a "System One" model, not an LLM) — only /v1/systemone is ingested.
|
|
41
|
+
- "api.typesafe.ai/v1/systemone"
|
|
42
|
+
# Ollama (self-hosted) has no fixed host, so an entry must pin BOTH the default
|
|
43
|
+
# port (11434) and the path — a bare "/api/chat" would capture any app's own
|
|
44
|
+
# unrelated chat endpoint. "*" matches one host label (localhost, an "ollama"
|
|
45
|
+
# compose service, a bare hostname); a LAN IP or dotted host needs its own
|
|
46
|
+
# entry, e.g. "192.168.1.5:11434/api/chat".
|
|
47
|
+
- "*:11434/api/chat"
|
|
48
|
+
- "*:11434/api/generate"
|
|
49
|
+
- "*:11434/api/embed"
|
|
50
|
+
- "*:11434/api/embeddings"
|
|
51
|
+
- "127.0.0.1:11434/api/chat"
|
|
52
|
+
- "127.0.0.1:11434/api/generate"
|
|
53
|
+
- "127.0.0.1:11434/api/embed"
|
|
54
|
+
- "127.0.0.1:11434/api/embeddings"
|
|
19
55
|
- "openrouter.ai"
|
|
56
|
+
- "opencode.ai"
|
|
57
|
+
# Azure OpenAI dedicated hosts (commercial, US Gov, China/21Vianet)
|
|
58
|
+
- "openai.azure.com"
|
|
59
|
+
- "openai.azure.us"
|
|
60
|
+
- "openai.azure.cn"
|
|
61
|
+
# Azure AI Services / Foundry multi-service hosts — path-anchored so Speech,
|
|
62
|
+
# Vision, Language and Content Safety on the same host are NOT captured
|
|
63
|
+
- "cognitiveservices.azure.com/openai/"
|
|
64
|
+
- "cognitiveservices.azure.com/models/"
|
|
65
|
+
- "cognitiveservices.azure.us/openai/"
|
|
66
|
+
- "cognitiveservices.azure.us/models/"
|
|
67
|
+
- "cognitiveservices.azure.cn/openai/"
|
|
68
|
+
- "cognitiveservices.azure.cn/models/"
|
|
69
|
+
- "services.ai.azure.com/openai/"
|
|
70
|
+
- "services.ai.azure.com/models/"
|
|
71
|
+
- "services.ai.azure.us/openai/"
|
|
72
|
+
- "services.ai.azure.us/models/"
|
|
73
|
+
# Serverless / MaaS + Azure ML managed online endpoints. inference.ml.azure.*
|
|
74
|
+
# is deliberately unanchored — see docs/configuration.md for why.
|
|
75
|
+
- "inference.ai.azure.com"
|
|
76
|
+
- "models.ai.azure.com"
|
|
77
|
+
- "inference.ml.azure.com"
|
|
78
|
+
- "inference.ml.azure.us"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Colon-action path suffixes from Google's API Discovery convention
|
|
2
|
+
# (e.g. ".../v1beta/models/gemini-pro:generateContent"). These have no host
|
|
3
|
+
# component of their own, so — unlike intercept_addresses — they are only
|
|
4
|
+
# ever matched against the *path* of requests whose host is already
|
|
5
|
+
# googleapis.com or a googleapis.com subdomain. They can never match on
|
|
6
|
+
# their own against an unrelated/attacker-controlled host.
|
|
7
|
+
#
|
|
8
|
+
# Users can extend defaults: c.intercept_path_patterns << ":myAction"
|
|
9
|
+
# Users can override entirely: c.intercept_path_patterns = [":onlyThis"]
|
|
10
|
+
|
|
11
|
+
- ":generateContent"
|
|
12
|
+
- ":streamGenerateContent"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised by the gem's read methods when the Coolhand API answers with a non-2xx status.
|
|
7
|
+
#
|
|
8
|
+
# `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
|
|
9
|
+
# message text.
|
|
10
|
+
class HttpError < Error
|
|
11
|
+
attr_reader :status, :body
|
|
12
|
+
|
|
13
|
+
def initialize(message, status:, body: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status = status
|
|
16
|
+
@body = body
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -44,7 +44,7 @@ module Coolhand
|
|
|
44
44
|
headers: sanitize_headers(headers),
|
|
45
45
|
request_body: clean_webhook_body(webhook_body, source),
|
|
46
46
|
response_body: options[:response_body],
|
|
47
|
-
response_headers: options[:response_headers],
|
|
47
|
+
response_headers: options[:response_headers] && sanitize_headers(options[:response_headers]),
|
|
48
48
|
status_code: options[:status_code] || 200,
|
|
49
49
|
source: "#{source}_webhook"
|
|
50
50
|
}.merge(options.slice(:metadata, :conversation_id, :agent_id))
|