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
|
@@ -24,36 +24,77 @@ module Coolhand
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
@patch_mutex = Mutex.new
|
|
28
|
+
@patch_count = 0
|
|
29
|
+
@patched = false
|
|
30
|
+
|
|
31
|
+
# patch!/unpatch! are reference-counted so concurrent/nested callers (e.g. overlapping
|
|
32
|
+
# Coolhand.capture blocks across threads) compose safely — the interceptor only actually
|
|
33
|
+
# unpatches once every outstanding caller has released it. Coolhand.configure's patch! is
|
|
34
|
+
# never balanced by an unpatch!, so it permanently holds the count at >=1 for the process
|
|
35
|
+
# lifetime once the gem is enabled; that's intentional, not a leak.
|
|
36
|
+
#
|
|
37
|
+
# Unlike before, patch! is no longer idempotent on its own — every call must be matched by
|
|
38
|
+
# exactly one unpatch! to release it. A host app that calls Coolhand.configure more than once
|
|
39
|
+
# (e.g. a reloader re-running an initializer) will hold an extra, permanent reference each
|
|
40
|
+
# time rather than no-op'ing; harmless (interception simply stays on), but worth knowing.
|
|
27
41
|
def self.patch!
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
@patch_mutex.synchronize do
|
|
43
|
+
@patch_count += 1
|
|
44
|
+
next if @patched
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
Net::HTTP.prepend(self)
|
|
48
|
+
Net::HTTPResponse.prepend(ResponseInterceptor)
|
|
49
|
+
|
|
50
|
+
@patched = true
|
|
51
|
+
Coolhand.log "🔗 Net::HTTP interceptor patched"
|
|
52
|
+
rescue StandardError
|
|
53
|
+
# Roll back this call's hold — it never actually took effect, so it must not count
|
|
54
|
+
# toward the refcount or a legitimate later unpatch! would underflow against it. This
|
|
55
|
+
# branch only runs when @patched was false on entry (see `next if @patched` above), so
|
|
56
|
+
# 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.
|
|
58
|
+
@patch_count -= 1
|
|
59
|
+
@patched = false
|
|
60
|
+
raise
|
|
61
|
+
end
|
|
62
|
+
end
|
|
35
63
|
end
|
|
36
64
|
|
|
37
65
|
def self.unpatch!
|
|
38
66
|
# NOTE: With prepend, there's no clean way to unpatch
|
|
39
67
|
# We'll mark it as unpatched so it can be re-patched
|
|
40
|
-
@
|
|
41
|
-
|
|
68
|
+
@patch_mutex.synchronize do
|
|
69
|
+
@patch_count -= 1 if @patch_count.positive?
|
|
70
|
+
next if @patch_count.positive? || !@patched
|
|
71
|
+
|
|
72
|
+
@patched = false
|
|
73
|
+
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
74
|
+
end
|
|
42
75
|
end
|
|
43
76
|
|
|
44
77
|
def self.patched?
|
|
45
78
|
@patched
|
|
46
79
|
end
|
|
47
80
|
|
|
81
|
+
# Testing-only: force a clean slate. Real callers should only ever use balanced
|
|
82
|
+
# patch!/unpatch! pairs.
|
|
83
|
+
def self.reset!
|
|
84
|
+
@patch_mutex.synchronize do
|
|
85
|
+
@patch_count = 0
|
|
86
|
+
@patched = false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
48
90
|
def request(req, body = nil, &block)
|
|
49
91
|
return super unless NetHttpInterceptor.patched?
|
|
50
92
|
|
|
51
93
|
active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
|
|
52
94
|
return super if active.key?(self)
|
|
53
95
|
|
|
54
|
-
url =
|
|
55
|
-
return super unless
|
|
56
|
-
return super unless should_capture?
|
|
96
|
+
url = capturable_url(req)
|
|
97
|
+
return super unless url
|
|
57
98
|
|
|
58
99
|
# Capture body before setting the guard — if this raises we skip logging cleanly
|
|
59
100
|
# and the guard is never set, so there is no leak. A failure here (e.g. an
|
|
@@ -131,16 +172,41 @@ module Coolhand
|
|
|
131
172
|
end
|
|
132
173
|
|
|
133
174
|
def capture_request_body(req, body)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
138
183
|
content = req.body_stream.read
|
|
139
184
|
req.body_stream = StringIO.new(content)
|
|
140
|
-
return parse_json(content)
|
|
141
185
|
end
|
|
186
|
+
return nil if content.nil?
|
|
142
187
|
|
|
143
|
-
|
|
188
|
+
cap_and_parse(content, req)
|
|
189
|
+
end
|
|
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
|
|
195
|
+
|
|
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)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
parse_json(content)
|
|
203
|
+
end
|
|
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
|
|
144
210
|
end
|
|
145
211
|
|
|
146
212
|
def extract_status_from_exception(e)
|
|
@@ -153,22 +219,93 @@ module Coolhand
|
|
|
153
219
|
|
|
154
220
|
def intercept?(url)
|
|
155
221
|
return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
|
|
156
|
-
return false if excluded_by_pattern?(url)
|
|
157
222
|
|
|
158
|
-
|
|
223
|
+
uri = safe_parse(url)
|
|
224
|
+
return false unless uri&.host
|
|
225
|
+
|
|
226
|
+
return false if excluded_by_pattern?(uri)
|
|
227
|
+
|
|
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) } }
|
|
233
|
+
|
|
234
|
+
return false unless google_api_host_configured?(addresses)
|
|
235
|
+
return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
|
|
236
|
+
|
|
237
|
+
Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
|
|
238
|
+
end
|
|
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
|
|
159
248
|
end
|
|
160
249
|
|
|
161
|
-
def excluded_by_pattern?(
|
|
250
|
+
def excluded_by_pattern?(uri)
|
|
162
251
|
patterns = Coolhand.configuration.exclude_api_patterns
|
|
163
252
|
return false if patterns.nil? || patterns.empty?
|
|
164
253
|
|
|
165
|
-
|
|
254
|
+
path = uri.path.to_s
|
|
255
|
+
matched = patterns.find { |pattern| path.include?(pattern) }
|
|
166
256
|
if matched && Coolhand.configuration.debug_mode
|
|
167
|
-
Coolhand.log "🚫 Skipping capture for #{sanitize_url(
|
|
257
|
+
Coolhand.log "🚫 Skipping capture for #{sanitize_url(uri.to_s)} (matched exclude_api_pattern: \"#{matched}\")"
|
|
168
258
|
end
|
|
169
259
|
!!matched
|
|
170
260
|
end
|
|
171
261
|
|
|
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")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
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
|
|
291
|
+
|
|
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?("*")
|
|
298
|
+
|
|
299
|
+
regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
|
|
300
|
+
!!(host =~ regex)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def safe_parse(url)
|
|
304
|
+
URI.parse(url)
|
|
305
|
+
rescue URI::InvalidURIError
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
|
|
172
309
|
def build_url_for_request(http, req)
|
|
173
310
|
return req.path if %r{\Ahttps?://}.match?(req.path)
|
|
174
311
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
module OpenAi
|
|
5
|
+
# Thread-safe, in-memory, TTL-bounded store used to detect replayed
|
|
6
|
+
# `webhook-id` values. This is the default for
|
|
7
|
+
# `Coolhand.configuration.webhook_id_store` and only dedupes within a
|
|
8
|
+
# single process - deployments running multiple processes/dynos should
|
|
9
|
+
# supply their own store (e.g. backed by Rails.cache) via configuration.
|
|
10
|
+
class WebhookIdStore
|
|
11
|
+
def initialize
|
|
12
|
+
@entries = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Atomically checks-and-records `id` in a single critical section, so
|
|
17
|
+
# two concurrent replays of the same id can't both observe "unseen"
|
|
18
|
+
# before either records it. Returns true the first time `id` is
|
|
19
|
+
# claimed, false if it was already claimed within its TTL.
|
|
20
|
+
def claim!(id, ttl_seconds)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
prune
|
|
23
|
+
return false if @entries.key?(id)
|
|
24
|
+
|
|
25
|
+
@entries[id] = Time.now.to_i + ttl_seconds
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def seen?(id)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
prune
|
|
33
|
+
@entries.key?(id)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def prune
|
|
40
|
+
now = Time.now.to_i
|
|
41
|
+
@entries.delete_if { |_id, expires_at| expires_at < now }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "openssl"
|
|
4
4
|
|
|
5
|
+
require_relative "webhook_id_store"
|
|
6
|
+
|
|
5
7
|
module Coolhand
|
|
6
8
|
module OpenAi
|
|
7
9
|
class WebhookValidator
|
|
@@ -23,7 +25,7 @@ module Coolhand
|
|
|
23
25
|
secret_bytes = extract_secret_bytes
|
|
24
26
|
webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
|
|
25
27
|
|
|
26
|
-
return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp
|
|
28
|
+
return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp && webhook_id
|
|
27
29
|
|
|
28
30
|
verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
|
|
29
31
|
end
|
|
@@ -40,7 +42,8 @@ module Coolhand
|
|
|
40
42
|
return true if @payload
|
|
41
43
|
|
|
42
44
|
if should_enforce_strict_validation?
|
|
43
|
-
@errors << "Empty webhook payload - rejecting webhook
|
|
45
|
+
@errors << "Empty webhook payload - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
|
|
46
|
+
"not in development/test allowlist)"
|
|
44
47
|
Rails.logger.error(@errors.last)
|
|
45
48
|
false
|
|
46
49
|
else
|
|
@@ -51,7 +54,8 @@ module Coolhand
|
|
|
51
54
|
|
|
52
55
|
def validate_in_non_production_env
|
|
53
56
|
if should_enforce_strict_validation?
|
|
54
|
-
@errors << "OpenAI webhook secret not configured - rejecting webhook
|
|
57
|
+
@errors << "OpenAI webhook secret not configured - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
|
|
58
|
+
"not in development/test allowlist)"
|
|
55
59
|
Rails.logger.error(@errors.last)
|
|
56
60
|
false
|
|
57
61
|
else
|
|
@@ -80,8 +84,8 @@ module Coolhand
|
|
|
80
84
|
|
|
81
85
|
def validate_headers_in_non_production_env
|
|
82
86
|
if should_enforce_strict_validation?
|
|
83
|
-
@errors << "Missing OpenAI webhook signature or
|
|
84
|
-
"rejecting webhook in
|
|
87
|
+
@errors << "Missing OpenAI webhook signature, timestamp, or id headers - " \
|
|
88
|
+
"rejecting webhook (Rails.env=#{Rails.env.inspect} not in development/test allowlist)"
|
|
85
89
|
Rails.logger.error(@errors.last)
|
|
86
90
|
false
|
|
87
91
|
else
|
|
@@ -94,15 +98,46 @@ module Coolhand
|
|
|
94
98
|
signed_payload = "#{webhook_id}.#{webhook_timestamp}.#{@payload}"
|
|
95
99
|
expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
# An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
|
|
102
|
+
signature_valid = !secret_bytes.empty? && webhook_signature.start_with?("v1,") &&
|
|
98
103
|
secure_compare(webhook_signature[3..], expected_signature)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
else
|
|
104
|
+
|
|
105
|
+
unless signature_valid
|
|
102
106
|
@errors << "OpenAI webhook signature verification failed"
|
|
103
107
|
Rails.logger.error(@errors.last)
|
|
104
|
-
false
|
|
108
|
+
return false
|
|
105
109
|
end
|
|
110
|
+
|
|
111
|
+
return false unless timestamp_fresh?(webhook_timestamp)
|
|
112
|
+
return false unless webhook_id_unused?(webhook_id)
|
|
113
|
+
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Checked only after the signature is confirmed valid, so an
|
|
118
|
+
# unsigned/forged request can't poison the id-dedup store (or fail a
|
|
119
|
+
# freshness check) and DoS a later legitimate webhook with the same id.
|
|
120
|
+
def timestamp_fresh?(webhook_timestamp)
|
|
121
|
+
tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
|
|
122
|
+
age = (Time.now.to_i - webhook_timestamp.to_i).abs
|
|
123
|
+
return true if age <= tolerance
|
|
124
|
+
|
|
125
|
+
@errors << "OpenAI webhook timestamp outside replay-protection tolerance window"
|
|
126
|
+
Rails.logger.error(@errors.last)
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def webhook_id_unused?(webhook_id)
|
|
131
|
+
tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
|
|
132
|
+
return true if id_store.claim!(webhook_id, tolerance)
|
|
133
|
+
|
|
134
|
+
@errors << "OpenAI webhook id already processed (replay protection)"
|
|
135
|
+
Rails.logger.error(@errors.last)
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def id_store
|
|
140
|
+
Coolhand.configuration.webhook_id_store
|
|
106
141
|
end
|
|
107
142
|
|
|
108
143
|
def secure_compare(a, b)
|
|
@@ -124,7 +159,7 @@ module Coolhand
|
|
|
124
159
|
end
|
|
125
160
|
|
|
126
161
|
def should_enforce_strict_validation?
|
|
127
|
-
[
|
|
162
|
+
!%w[development test].include?(Rails.env)
|
|
128
163
|
end
|
|
129
164
|
end
|
|
130
165
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Paging state for a v2 list endpoint. These send a bare JSON array and carry paging in the
|
|
5
|
+
# `X-Page`, `X-Per-Page`, `X-Total-Count` and `X-Total-Pages` headers, never in the body.
|
|
6
|
+
Pagination = Struct.new(
|
|
7
|
+
:current_page,
|
|
8
|
+
:per_page,
|
|
9
|
+
:total_count,
|
|
10
|
+
:total_pages,
|
|
11
|
+
:has_next_page,
|
|
12
|
+
:has_prev_page,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
class Pagination
|
|
17
|
+
# Mirrors of the v2 controllers' values, used only to fill a header the server did not send.
|
|
18
|
+
DEFAULT_PER_PAGE = 25
|
|
19
|
+
MAX_PER_PAGE = 100
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def from_headers(response, items:, page: nil, per: nil)
|
|
23
|
+
requested_page = positive_int(page) || 1
|
|
24
|
+
requested_per = [positive_int(per) || DEFAULT_PER_PAGE, MAX_PER_PAGE].min
|
|
25
|
+
|
|
26
|
+
current_page = header_int(response, "X-Page") || requested_page
|
|
27
|
+
per_page = header_int(response, "X-Per-Page") || requested_per
|
|
28
|
+
reported_total_pages = header_int(response, "X-Total-Pages")
|
|
29
|
+
total_count = header_int(response, "X-Total-Count") || fallback_total_count(current_page, per_page, items)
|
|
30
|
+
total_pages = reported_total_pages || fallback_total_pages(total_count, per_page)
|
|
31
|
+
|
|
32
|
+
new(
|
|
33
|
+
current_page: current_page,
|
|
34
|
+
per_page: per_page,
|
|
35
|
+
total_count: total_count,
|
|
36
|
+
total_pages: total_pages,
|
|
37
|
+
has_next_page: next_page?(reported_total_pages, current_page, per_page, items),
|
|
38
|
+
has_prev_page: current_page > 1
|
|
39
|
+
).freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Falling back to the computed totals here would report "no next page" for a full page, and
|
|
45
|
+
# silently truncate a caller's loop.
|
|
46
|
+
def next_page?(reported_total_pages, current_page, per_page, items)
|
|
47
|
+
return current_page < reported_total_pages if reported_total_pages
|
|
48
|
+
return false unless per_page.positive?
|
|
49
|
+
|
|
50
|
+
items.size >= per_page
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A lower bound, not a count: every earlier page assumed full, plus this page.
|
|
54
|
+
def fallback_total_count(current_page, per_page, items)
|
|
55
|
+
return items.size unless per_page.positive?
|
|
56
|
+
|
|
57
|
+
[((current_page - 1) * per_page) + items.size, items.size].max
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fallback_total_pages(total_count, per_page)
|
|
61
|
+
return total_count.positive? ? 1 : 0 unless per_page.positive?
|
|
62
|
+
|
|
63
|
+
(total_count.to_f / per_page).ceil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Neither `Integer()` nor `to_i` is safe alone: the first raises on `""`, the second turns
|
|
67
|
+
# `"3.5"` into `3` and `"nonsense"` into `0` — a fabricated, legitimate-looking count.
|
|
68
|
+
def header_int(response, name)
|
|
69
|
+
raw = response[name]
|
|
70
|
+
return nil if raw.nil?
|
|
71
|
+
|
|
72
|
+
value = raw.strip
|
|
73
|
+
value.match?(/\A\d+\z/) ? value.to_i : nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def positive_int(value)
|
|
77
|
+
integer = Integer(value, exception: false)
|
|
78
|
+
integer&.positive? ? integer : nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Coolhand
|
|
9
|
+
# The GET half of {ApiService}, split out to keep that class inside this repo's 200-line budget.
|
|
10
|
+
#
|
|
11
|
+
# Reads raise where writes log-and-return-nil, on purpose: a write is instrumentation inline in
|
|
12
|
+
# the host app's request, while a read's caller must tell a 404 from a timeout from an empty result.
|
|
13
|
+
module ReadRequests
|
|
14
|
+
# 60s is deliberate, not a slip: writes allow 5, but the server bounds each *statement* at 10s
|
|
15
|
+
# and one response runs several. A tighter read timeout pre-empts the 504 callers should retry.
|
|
16
|
+
READ_OPEN_TIMEOUT = 5
|
|
17
|
+
READ_TIMEOUT = 60
|
|
18
|
+
|
|
19
|
+
# ERROR_BODY_LIMIT caps the message; this caps the body the exception object itself carries.
|
|
20
|
+
RETAINED_ERROR_BODY_LIMIT = 8_000
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
def get_json(url, noun)
|
|
25
|
+
body, = get_json_with_headers(url, noun)
|
|
26
|
+
body
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Also returns the response, for endpoints that carry pagination in headers rather than the body.
|
|
30
|
+
def get_json_with_headers(url, noun)
|
|
31
|
+
raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
|
|
32
|
+
|
|
33
|
+
response = perform_get(url, noun)
|
|
34
|
+
|
|
35
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
36
|
+
raise HttpError.new(
|
|
37
|
+
"#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
|
|
38
|
+
status: response.code.to_i,
|
|
39
|
+
body: retained_error_body(response.body)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[parse_json_body(response.body, noun), response]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def perform_get(url, noun)
|
|
49
|
+
uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
|
|
50
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
51
|
+
http.use_ssl = (uri.scheme == "https")
|
|
52
|
+
http.open_timeout = READ_OPEN_TIMEOUT
|
|
53
|
+
http.read_timeout = READ_TIMEOUT
|
|
54
|
+
|
|
55
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
56
|
+
apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
|
|
57
|
+
|
|
58
|
+
# Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
|
|
59
|
+
# an unapproved host. without_capture is the same recursion guard send_request uses.
|
|
60
|
+
Coolhand.without_capture { http.request(request) }
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
raise Error, "#{noun} request failed: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def retained_error_body(body)
|
|
66
|
+
return body if body.nil? || body.length <= RETAINED_ERROR_BODY_LIMIT
|
|
67
|
+
|
|
68
|
+
"#{body[0, RETAINED_ERROR_BODY_LIMIT]}... [truncated]"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_json_body(body, noun)
|
|
72
|
+
JSON.parse(body.to_s, symbolize_names: true)
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
raise Error, "#{noun} response was not valid JSON: #{format_error_body(body)}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "api_service"
|
|
5
|
+
require_relative "pagination"
|
|
6
|
+
|
|
7
|
+
module Coolhand
|
|
8
|
+
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
|
+
TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# Read-only. Requires the client's **private** key — the public key is write-only here and is
|
|
12
|
+
# rejected like an invalid one.
|
|
13
|
+
#
|
|
14
|
+
# Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
|
|
15
|
+
# See docs/template-search.md.
|
|
16
|
+
class TemplateService < ApiService
|
|
17
|
+
ERROR_NOUN = "Template"
|
|
18
|
+
BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
super("v2/llm_request_templates")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Filters, error semantics and server behaviour: docs/template-search.md.
|
|
25
|
+
def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
|
|
26
|
+
include_system: nil, page: nil, per: nil)
|
|
27
|
+
query = {
|
|
28
|
+
search: search,
|
|
29
|
+
workload_id: workload_id,
|
|
30
|
+
status: status,
|
|
31
|
+
include_deprecated: include_deprecated,
|
|
32
|
+
include_system: include_system,
|
|
33
|
+
page: page,
|
|
34
|
+
per: per
|
|
35
|
+
}.compact
|
|
36
|
+
|
|
37
|
+
templates, response = get_json_with_headers(list_url(query), ERROR_NOUN)
|
|
38
|
+
raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
|
|
39
|
+
|
|
40
|
+
TemplateSearchResult.new(
|
|
41
|
+
templates: templates,
|
|
42
|
+
pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
|
|
43
|
+
).freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
|
|
47
|
+
# list reaches deprecated and system templates by id with no opt-in flag.
|
|
48
|
+
def get_template(id)
|
|
49
|
+
get_json(resource_url(id), ERROR_NOUN)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def list_url(query)
|
|
55
|
+
uri = URI.parse(api_endpoint)
|
|
56
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
57
|
+
uri
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resource_url(id)
|
|
61
|
+
raise Error, BLANK_ID_MESSAGE unless id.is_a?(String)
|
|
62
|
+
|
|
63
|
+
trimmed = id.strip
|
|
64
|
+
# A blank id resolves to the index route (bare array, not one template); a bare dot segment
|
|
65
|
+
# retargets the request at another path. Neither would 404, so both are rejected here.
|
|
66
|
+
raise Error, BLANK_ID_MESSAGE if trimmed.empty? || [".", ".."].include?(trimmed)
|
|
67
|
+
|
|
68
|
+
URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
|
|
72
|
+
def escape_path_segment(value)
|
|
73
|
+
URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/coolhand/version.rb
CHANGED