squishling 0.5.0 → 0.5.1

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.
data/docs/verifying.md CHANGED
@@ -27,8 +27,10 @@ Squishling.verify(Triage, Invoice) # just these classes
27
27
  For each squished method (inherited ones too, with the subclass's settings) it checks:
28
28
 
29
29
  - **The output schema:** that it builds (and is strict), is valid JSON Schema, and produces a typed result, so a
30
- property name that can't be a result member (`total=`) is reported. A method with no output schema or no purpose is reported, because its LLM path
31
- would raise. The default `call` method is skipped when you don't implement or configure it.
30
+ property name that can't be a result member (`total=`), an object with `properties` but no `type: "object"`, or a
31
+ `$ref` that doesn't resolve within the schema (a remote URI is never fetched; a missing `$defs` entry) is reported.
32
+ A method with no output schema or no purpose is reported, because its LLM path would raise. The default `call`
33
+ method is skipped when you don't implement or configure it.
32
34
  - **What the layers resolve to:** the escalation, generation params and `squawk` that the method, its class and
33
35
  `Squishling.config` add up to.
34
36
  - **The harness:** that it is still registered (not removed or replaced since the class declared it), and that a
@@ -11,7 +11,8 @@ module Squishling
11
11
  # repeated.
12
12
  WORD = /\A[A-Za-z0-9][\w.]{0,39}\z/
13
13
  PATH = /\A[\w.\-\[\]]{1,64}\z/
14
- private_constant :WORD, :PATH
14
+ CONTEXT_LENGTH_EXCEEDED = "context_length_exceeded"
15
+ private_constant :WORD, :PATH, :CONTEXT_LENGTH_EXCEEDED
15
16
 
16
17
  module_function
17
18
 
@@ -38,9 +39,15 @@ module Squishling
38
39
  nil
39
40
  end
40
41
 
42
+ # Whether the body's code or type is the structured "context_length_exceeded" (what OpenAI sends for a prompt too
43
+ # long for the model). Only that field is read, never the message.
44
+ def context_length?(body)
45
+ (error(body)&.values_at("code", "type") || []).include?(CONTEXT_LENGTH_EXCEEDED)
46
+ end
47
+
41
48
  def token(value, pattern)
42
49
  value = value.to_s if value.is_a?(Integer)
43
- value if value.is_a?(String) && value.match?(pattern)
50
+ value if value.is_a?(String) && value.valid_encoding? && value.match?(pattern)
44
51
  end
45
52
  end
46
53
  end
@@ -69,6 +69,11 @@ module Squishling
69
69
  end
70
70
  end
71
71
 
72
+ # What an adapter puts on a Squishling error's #cause in place of a provider's own exception (see
73
+ # Base#sanitized_cause): the original's class name and HTTP status in its message, and its backtrace, but none of
74
+ # its message, response, or other state. Not a Squishling::Error: it is never raised, only carried.
75
+ class ProviderFailure < StandardError; end
76
+
72
77
  # The adapter contract. Subclass it, implement #complete, and raise Squishling's errors:
73
78
  #
74
79
  # class MyAdapter < Squishling::Adapter::Base
@@ -78,15 +83,20 @@ module Squishling
78
83
  # rescue MyClient::Unauthorized
79
84
  # raise configuration_error(request, "MyClient rejected the credentials")
80
85
  # rescue MyClient::Error => e
81
- # raise llm_error(request, "MyClient failed (#{e.class})")
86
+ # raise llm_error(request, "MyClient failed (#{e.class})"), cause: sanitized_cause(e, status: e.status)
82
87
  # end
83
88
  # end
84
89
  #
85
90
  # ConfigurationError is for setup mistakes (bad credentials, an invalid request, an unknown model): it is never
86
91
  # retried or escalated. LLMError is for failures worth another attempt (rate limits, 5xx, timeouts, connection
87
- # errors): the next attempt, or the last one's caller, sees it. Keep the provider's own message out of both: it can
88
- # echo request content or part of a key. Any other exception propagates unwrapped. Adapters are called from worker
89
- # threads when a harness takes samples, so they must not share unsynchronized state.
92
+ # errors, a prompt too long for the model): the next attempt, or the last one's caller, sees it. Keep the provider's
93
+ # own message out of both: it can echo request content or part of a key. The same goes for the exception it came
94
+ # from, which error trackers report as the error's #cause: pass `cause: sanitized_cause(e, status:)` rather than
95
+ # letting a raise inside a rescue set a provider exception that carries a response body. Any other exception
96
+ # propagates unwrapped.
97
+ # Which of the two a provider failure is, is the adapter's call (#http_failure maps HTTP statuses, and
98
+ # #context_length? is where an adapter recognizes an overlong prompt); what happens next is Squishling's.
99
+ # Adapters are called from worker threads when a harness takes samples, so they must not share unsynchronized state.
90
100
  class Base
91
101
  # One completion. Returns an Adapter::Response.
92
102
  def complete(_request)
@@ -126,10 +136,53 @@ module Squishling
126
136
  LLMError.new("#{request.label}: #{message}")
127
137
  end
128
138
 
129
- # A RequestRejectedError for a request the provider rejected (a 400). via names how it failed (the error class,
130
- # the HTTP status); body is the provider's error body (a JSON String, or a Hash with String keys), of which only
131
- # the status and code tokens are repeated, never its message. http_status, when given, drops a code that only
132
- # repeats it. The params are pointed at only when the provider named one of them.
139
+ # What to pass as `cause:` when raising a Squishling error for a provider's exception: a ProviderFailure naming
140
+ # the original's class and the HTTP status (when given), with the original's backtrace. A provider exception's
141
+ # message and response usually hold the provider's response body, which can echo request content, and error
142
+ # trackers (Sentry, say) report the whole cause chain. Built fresh, never a copy of the original, so none of
143
+ # its state (a Faraday response, say) comes along. Transport errors that carry no body can be passed as they are.
144
+ def sanitized_cause(error, status: nil)
145
+ ProviderFailure.new([error.class, ("HTTP #{status}" if status)].compact.join(", "))
146
+ .tap { |failure| failure.set_backtrace(error.backtrace) }
147
+ end
148
+
149
+ # Classifies a provider's HTTP failure, so adapters sort the same status the same way: 408, 413, 429, and 5xx are
150
+ # LLMErrors; 401, 402, 403, 404, and a redirect are ConfigurationErrors (credentials, billing, model or endpoint:
151
+ # nothing a retry fixes); every other 4xx is a RequestRejectedError, unless #context_length? says the input
152
+ # outgrew the model, which is an LLMError too (a bigger model may take it). via names how it failed (the error
153
+ # class, the HTTP status) and is all of the failure that is repeated, with a rejection's status and code tokens.
154
+ # body is the provider's error body, as for #rejected_request_error. A nil status (no HTTP response) or a
155
+ # success status (a reply that was unusable) is an LLMError.
156
+ def http_failure(request, status:, body:, via:)
157
+ case status
158
+ when nil, 100..299, 408, 413, 429, 500.. then llm_error(request, via)
159
+ when 401, 403 then configuration_error(request, "the provider refused the credentials (#{via})")
160
+ when 402 then configuration_error(request, "#{via}: the provider reports a billing or quota problem")
161
+ when 404 then configuration_error(request, "#{via}: check the model name and the endpoint")
162
+ when 300..399 then configuration_error(request, "#{via}: redirects aren't followed; check the endpoint")
163
+ else rejected_or_overlong(request, status:, body:, via:)
164
+ end
165
+ end
166
+
167
+ # Whether a rejected request (a 400, or another 4xx not otherwise classified) means the input was too long for the
168
+ # model, which makes it an LLMError rather than a RequestRejectedError. The adapter decides: only it knows how its
169
+ # provider says so. The default recognizes a 400 whose structured error code or type is "context_length_exceeded",
170
+ # never reading the message. Override it to recognize more (and call super); when an adapter can't tell, the
171
+ # failure stays a rejection. status and body are those given to #http_failure.
172
+ def context_length?(status:, body:)
173
+ status == 400 && ErrorBody.context_length?(body)
174
+ end
175
+
176
+ def rejected_or_overlong(request, status:, body:, via:)
177
+ return llm_error(request, via) if context_length?(status:, body:)
178
+
179
+ rejected_request_error(request, via:, body:, http_status: status)
180
+ end
181
+
182
+ # A RequestRejectedError for a request the provider rejected (a 400, or another 4xx). via names how it failed
183
+ # (the error class, the HTTP status); body is the provider's error body (a JSON String, or a Hash with String
184
+ # keys), of which only the status and code tokens are repeated, never its message. http_status, when given, drops
185
+ # a code that only repeats it. The params are pointed at only when the provider named one of them.
133
186
  def rejected_request_error(request, via:, body:, http_status: nil)
134
187
  found = ErrorBody.read(body, http_status:)
135
188
  tokens = found.values_at(:status, :code).compact
@@ -219,7 +272,7 @@ module Squishling
219
272
  return value if value.respond_to?(:complete)
220
273
 
221
274
  raise ConfigurationError, "#{where} adapter must be an adapter (responding to complete), one of " \
222
- "#{type_list}, or a Hash with type:, got #{value.inspect}"
275
+ "#{type_list}, or a Hash with type:, got #{Appendices.describe(value)}"
223
276
  end
224
277
  end
225
278
 
@@ -19,7 +19,8 @@ module Squishling
19
19
  # end
20
20
  class OpenAICompatible < Adapter::Base
21
21
  Options = OpenAIOptions
22
- private_constant :Options
22
+ Transport = OpenAITransport
23
+ private_constant :Options, :Transport
23
24
 
24
25
  DEFAULT_BASE_URL = "https://api.openai.com/v1"
25
26
  STRUCTURED_OUTPUT = %i[json_schema json_object none].freeze
@@ -30,10 +31,12 @@ module Squishling
30
31
  # 429, and 5xx. A timeout is retried even though the server may have processed the request.
31
32
  NETWORK_ERRORS = [Timeout::Error, SocketError, EOFError, IOError, SystemCallError, Net::ProtocolError,
32
33
  Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, OpenSSL::OpenSSLError, Zlib::Error].freeze
34
+ DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
33
35
  MAX_RETRY_DELAY = 30
34
- # How servers without OpenAI's error code (vLLM, Ollama, llama.cpp, ...) say a prompt is too long. The message is
35
- # only matched against this, never repeated.
36
- CONTEXT_LENGTH_MESSAGE = /context (length|window|size)|too many tokens|prompt is too long|maximum .{0,20}tokens/i
36
+ # How servers without OpenAI's error code (vLLM, Ollama, llama.cpp, ...) say a prompt is too long. Deliberately
37
+ # narrow (no bare "maximum ... tokens", which a bad max_tokens value also says): a wording that isn't here is a
38
+ # rejected request, which is the safe side. The message is only matched against this, never repeated.
39
+ CONTEXT_LENGTH_MESSAGE = /context (length|window|size)|too many tokens|prompt is too long/i
37
40
  private_constant :STRUCTURED_OUTPUT, :TOKEN_PARAMS, :PORTABLE_PARAMS, :NETWORK_ERRORS, :MAX_RETRY_DELAY,
38
41
  :CONTEXT_LENGTH_MESSAGE
39
42
 
@@ -44,23 +47,30 @@ module Squishling
44
47
  # max_retries: how often a timeout, connection failure, HTTP 408, 429, or 5xx is retried here (honoring
45
48
  # Retry-After) before it surfaces as an LLMError. max_tokens_param: the body key for max_output_tokens
46
49
  # (:max_completion_tokens, or :max_tokens for older servers). structured_output: :json_schema, :json_object,
47
- # or :none. Raises ConfigurationError for a bad option.
50
+ # or :none. allow_insecure_http: lets a credential (api_key, or a header such as Authorization or api-key) go
51
+ # over plain http to a host that isn't loopback; without it that is a ConfigurationError. max_response_bytes:
52
+ # the most a response (an error body too) may hold, 8 MiB by default. total_timeout: the most seconds one
53
+ # attempt may take (read_timeout restarts at every read); it defaults to open_timeout + 2 * read_timeout.
54
+ # Responses are requested and accepted uncompressed only. Raises ConfigurationError for a bad option.
48
55
  def initialize(base_url: DEFAULT_BASE_URL, api_key: nil, headers: {}, default_model: nil, open_timeout: 10,
49
- read_timeout: 120, max_retries: 2, max_tokens_param: :max_completion_tokens, structured_output: :json_schema)
56
+ read_timeout: 120, max_retries: 2, max_tokens_param: :max_completion_tokens, structured_output: :json_schema,
57
+ allow_insecure_http: false, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, total_timeout: nil)
50
58
  super()
51
59
  @endpoint = Options.endpoint(base_url)
52
- @api_key = api_key
53
- @headers = Options.headers(headers)
60
+ @api_key = (Secret.new(api_key) unless api_key.nil?)
61
+ @headers = Secret.new(Options.headers(headers))
62
+ @allow_insecure_http = Options.flag(:allow_insecure_http, allow_insecure_http)
54
63
  @default_model = Options.default_model(default_model)
55
- @open_timeout = Options.number(:open_timeout, open_timeout)
56
- @read_timeout = Options.number(:read_timeout, read_timeout)
57
64
  @max_retries = Options.retries(max_retries)
58
65
  @max_tokens_param = Options.choice(:max_tokens_param, max_tokens_param, TOKEN_PARAMS)
59
66
  @structured_output = Options.choice(:structured_output, structured_output, STRUCTURED_OUTPUT)
67
+ @transport = build_transport(open_timeout, read_timeout, total_timeout, max_response_bytes)
68
+ check_cleartext!(api_key.respond_to?(:call) ? nil : api_key)
60
69
  end
61
70
 
62
71
  # Shows where requests go, never the key or the header values (a console, a log line, or an error tracker may
63
- # print the config).
72
+ # print the config). The key and headers are held in a Secret, so to_yaml, as_json and Marshal can't expose them
73
+ # either.
64
74
  def inspect
65
75
  "#<#{self.class.name} #{@endpoint.scheme}://#{@endpoint.authority}#{@endpoint.path} " \
66
76
  "default_model=#{@default_model.inspect} structured_output=#{@structured_output.inspect}>"
@@ -143,46 +153,64 @@ module Squishling
143
153
  loop do
144
154
  attempt += 1
145
155
  response, error = exchange(body, headers)
146
- return parse_body(request, response) if response.is_a?(Net::HTTPSuccess)
156
+ return parse_body(request, response) if response&.success?
147
157
 
148
158
  failure = failure_for(request, response, error)
149
159
  raise failure, cause: error unless retryable?(response, error) && attempt <= @max_retries
150
160
 
151
161
  pause(retry_delay(response, attempt))
152
162
  end
163
+ rescue Transport::Refused => e
164
+ # A size or time limit, or compression: the same request would hit it again, so it isn't retried.
165
+ raise llm_error(request, e.message), cause: e
153
166
  end
154
167
 
155
168
  def exchange(body, headers)
156
- [http_post(body, headers), nil]
169
+ [@transport.post(body, headers), nil]
157
170
  rescue *NETWORK_ERRORS => e
158
171
  [nil, e]
159
172
  end
160
173
 
161
- # Redirects aren't followed: the next hop could be another host, and the Authorization header would go with it.
162
- def http_post(body, headers)
163
- http = Net::HTTP.new(@endpoint.hostname, @endpoint.port)
164
- http.use_ssl = @endpoint.scheme == "https"
165
- http.open_timeout = @open_timeout
166
- http.read_timeout = @read_timeout
167
- http.write_timeout = @read_timeout
168
- post = Net::HTTP::Post.new(@endpoint.request_uri, headers)
169
- post.body = body
170
- http.request(post)
174
+ def build_transport(open_timeout, read_timeout, total_timeout, max_response_bytes)
175
+ open_timeout = Options.number(:open_timeout, open_timeout)
176
+ read_timeout = Options.number(:read_timeout, read_timeout)
177
+ Transport.new(endpoint: @endpoint, open_timeout:, read_timeout:,
178
+ total_timeout: Options.total_timeout(total_timeout, open_timeout, read_timeout),
179
+ max_response_bytes: Options.number(:max_response_bytes, max_response_bytes, type: Integer))
171
180
  end
172
181
 
173
182
  # Built once per call, before any request and outside the network-error rescue, so the developer's own api_key
174
183
  # callable runs once and its exceptions propagate as they are.
175
184
  def request_headers(request)
176
- key = (@api_key.respond_to?(:call) ? @api_key.call : (@api_key || default_api_key)).to_s.strip
185
+ key = api_key_value.to_s.strip
177
186
  if Options.unsafe_header?(key)
178
187
  raise configuration_error(request,
179
188
  "the api_key can't contain line breaks or NUL")
180
189
  end
190
+ check_cleartext!(key, request)
181
191
 
182
192
  headers = { "Content-Type" => "application/json", "Accept" => "application/json",
183
193
  "User-Agent" => "squishling/#{VERSION}" }
184
194
  headers["Authorization"] = "Bearer #{key}" unless key.empty?
185
- headers.merge(@headers)
195
+ # Accept-Encoding is Squishling's own: a compressed response would be inflated past the size cap.
196
+ headers.merge(@headers.reveal).reject { |name, _| name.casecmp?("accept-encoding") }
197
+ .merge("Accept-Encoding" => "identity")
198
+ end
199
+
200
+ def api_key_value
201
+ key = @api_key&.reveal
202
+ key.respond_to?(:call) ? key.call : (key || default_api_key)
203
+ end
204
+
205
+ # A credential doesn't go over plain http to another machine unless allow_insecure_http: says so. Checked when the
206
+ # adapter is built for a key given as a String, and again with each request's own key (a callable's). Reads the
207
+ # headers through their Secret; the message names neither the key nor a header value.
208
+ def check_cleartext!(key, request = nil)
209
+ return if @allow_insecure_http || !Options.cleartext_credential?(@endpoint, key, @headers.reveal)
210
+
211
+ raise configuration_error(request, Options::CLEARTEXT_CREDENTIAL) if request
212
+
213
+ raise ConfigurationError, Options::CLEARTEXT_CREDENTIAL
186
214
  end
187
215
 
188
216
  def default_api_key
@@ -213,9 +241,7 @@ module Squishling
213
241
  return transport_failure(request, error) if error
214
242
 
215
243
  status = response.code.to_i
216
- return llm_error(request, "HTTP #{status}") if retryable?(response, nil) || too_large?(response, status)
217
-
218
- rejection(request, response, status)
244
+ http_failure(request, status:, body: response.body.to_s, via: "HTTP #{status}")
219
245
  end
220
246
 
221
247
  # A certificate that doesn't verify is a setup mistake (base_url, CA store) that a retry can't fix.
@@ -232,32 +258,13 @@ module Squishling
232
258
  error.is_a?(OpenSSL::SSL::SSLError) && error.message.include?("certificate verify failed")
233
259
  end
234
260
 
235
- def rejection(request, response, status)
236
- case status
237
- when 401, 403 then configuration_error(request, "the provider refused the credentials (HTTP #{status})")
238
- when 404 then configuration_error(request, "HTTP 404: check base_url and the model name")
239
- when 300..399 then configuration_error(request, "HTTP #{status}: redirects aren't followed; check base_url")
240
- else rejected_request_error(request, via: "HTTP #{status}", body: response.body.to_s,
241
- http_status: status)
242
- end
243
- end
244
-
245
- # A request too long for the model's context (or for the server: 413) depends on the input and is worth another
246
- # attempt (a bigger model may take it), unlike other 4xx. Only the error's code is read from the body.
247
- def too_large?(response, status)
248
- status == 413 || context_length?(response)
249
- end
250
-
251
- def context_length?(response)
252
- return false unless response.code.to_i == 400
253
-
254
- error = Adapter::ErrorBody.error(response.body.to_s)
255
- error.is_a?(Hash) && (error.values_at("code", "type").include?("context_length_exceeded") ||
256
- error["message"].to_s.match?(CONTEXT_LENGTH_MESSAGE))
261
+ # The structured code or type, as for any adapter, or the usual wording of a server that sends none (a 400).
262
+ def context_length?(status:, body:)
263
+ super || (status == 400 && Adapter::ErrorBody.error(body)&.[]("message").to_s.scrub.match?(CONTEXT_LENGTH_MESSAGE))
257
264
  end
258
265
 
259
266
  def retry_delay(response, attempt)
260
- after = response&.[]("retry-after").to_s
267
+ after = response&.retry_after.to_s
261
268
  seconds = after.match?(/\A\d+\z/) ? after.to_i : (0.5 * (2**(attempt - 1))) + (rand * 0.25)
262
269
  [seconds, MAX_RETRY_DELAY].min
263
270
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
3
4
  require "uri"
4
5
 
5
6
  module Squishling
@@ -10,6 +11,12 @@ module Squishling
10
11
  # The URL itself is never in the message: it may carry credentials.
11
12
  INVALID_BASE_URL = "adapter :openai_compatible base_url must be an http(s) URL without credentials (use " \
12
13
  "api_key:), a query, or a fragment"
14
+ # Headers that carry a secret as a matter of course (OpenAI-style, Azure OpenAI and API Management, Google, and
15
+ # proxies), by name: an API or subscription key, a token, auth, a secret, a credential, or a cookie.
16
+ CREDENTIAL_HEADER = /(api|subscription)[-_]?key|token|auth|secret|credential|cookie/i
17
+ CLEARTEXT_CREDENTIAL = "adapter :openai_compatible won't send credentials (the api_key or a header such as " \
18
+ "Authorization or api-key) over plain http to a host that isn't this machine; use " \
19
+ "https, or pass allow_insecure_http: true to accept the risk on a network you trust"
13
20
  INVALID_HEADER = "adapter :openai_compatible headers (and the api_key) can't contain line breaks or NUL"
14
21
 
15
22
  module_function
@@ -40,6 +47,40 @@ module Squishling
40
47
  text.match?(/[\r\n\0]/)
41
48
  end
42
49
 
50
+ # Whether a credential would travel in the clear: over http, to a host that isn't loopback (a name is judged by
51
+ # its spelling, never resolved, so a lookup can't change the answer).
52
+ def cleartext?(endpoint)
53
+ endpoint.scheme == "http" && !loopback?(endpoint.hostname)
54
+ end
55
+
56
+ def loopback?(host)
57
+ host = host.downcase.delete_suffix(".")
58
+ host == "localhost" || host.end_with?(".localhost") || IPAddr.new(host).loopback?
59
+ rescue IPAddr::Error
60
+ false
61
+ end
62
+
63
+ # Whether a request to endpoint, with this key (a String, possibly blank) and these headers, would send a
64
+ # credential in the clear.
65
+ def cleartext_credential?(endpoint, key, headers)
66
+ return false unless cleartext?(endpoint)
67
+
68
+ !key.to_s.strip.empty? || headers.each_key.any? { |name| name.match?(CREDENTIAL_HEADER) }
69
+ end
70
+
71
+ def flag(name, value)
72
+ return value if [true, false].include?(value)
73
+
74
+ raise ConfigurationError, "adapter :openai_compatible #{name} must be true or false, got #{value.inspect}"
75
+ end
76
+
77
+ # Defaults to the time the three timeouts allow together: connecting, writing the request, one read.
78
+ def total_timeout(value, open_timeout, read_timeout)
79
+ return open_timeout + (2 * read_timeout) if value.nil?
80
+
81
+ number(:total_timeout, value)
82
+ end
83
+
43
84
  def default_model(model)
44
85
  return nil if model.nil?
45
86
  return model.to_s if (model.is_a?(String) || model.is_a?(Symbol)) && !model.to_s.strip.empty?
@@ -47,10 +88,11 @@ module Squishling
47
88
  raise ConfigurationError, "adapter :openai_compatible default_model must be a model name, got #{model.inspect}"
48
89
  end
49
90
 
50
- def number(name, value)
51
- return value if value.is_a?(Numeric) && value.positive?
91
+ def number(name, value, type: Numeric)
92
+ return value if value.is_a?(type) && value.positive?
52
93
 
53
- raise ConfigurationError, "adapter :openai_compatible #{name} must be a positive number, got #{value.inspect}"
94
+ kind = type == Integer ? "Integer" : "number"
95
+ raise ConfigurationError, "adapter :openai_compatible #{name} must be a positive #{kind}, got #{value.inspect}"
54
96
  end
55
97
 
56
98
  def retries(value)
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "timeout"
5
+
6
+ module Squishling
7
+ module Adapters
8
+ # The HTTP exchange of Adapters::OpenAICompatible, kept apart so the limits sit in one place: one POST whose
9
+ # response is read in chunks, with a cap on its size (the declared length, then the bytes actually received, for an
10
+ # error body too) and on the total time it may take, which read_timeout alone doesn't bound (it restarts at every
11
+ # read, so a slow drip of headers or body can hold a worker thread for as long as the server likes). Compression
12
+ # is refused, not inflated: the request asks for an identity encoding, and a response that comes back encoded
13
+ # anyway is dropped, so a small body can't expand past the cap. Redirects aren't followed: the next hop could be
14
+ # another host, and the Authorization header would go with it. A limit that is hit raises a Refused, whose
15
+ # message names the limit and never carries the URL, a header, or any of the response.
16
+ class OpenAITransport
17
+ class Refused < StandardError; end
18
+ class ResponseTooLarge < Refused; end
19
+ class DeadlineExceeded < Refused; end
20
+ class UnexpectedEncoding < Refused; end
21
+
22
+ # What is left of a response once it has been read: its status (a String, as Net::HTTPResponse has it), whole
23
+ # body, and Retry-After header.
24
+ Reply = Struct.new(:code, :body, :retry_after) do
25
+ def success?
26
+ code.start_with?("2")
27
+ end
28
+ end
29
+
30
+ MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
31
+ private_constant :MONOTONIC
32
+
33
+ def initialize(endpoint:, open_timeout:, read_timeout:, total_timeout:, max_response_bytes:, clock: MONOTONIC)
34
+ @endpoint = endpoint
35
+ @open_timeout = open_timeout
36
+ @read_timeout = read_timeout
37
+ @total_timeout = total_timeout
38
+ @max_response_bytes = max_response_bytes
39
+ @clock = clock
40
+ end
41
+
42
+ # total_timeout and the byte cap apply per call, so each retry gets its own.
43
+ def post(body, headers)
44
+ deadline = @clock.call + @total_timeout
45
+ perform(body, headers, deadline)
46
+ rescue DeadlineExceeded
47
+ # Net::HTTP re-raises what interrupts a connect with the host and port in the message; ours is built afresh.
48
+ expired!
49
+ rescue Timeout::Error
50
+ expired! if @clock.call >= deadline
51
+
52
+ raise
53
+ end
54
+
55
+ private
56
+
57
+ # Net::HTTP reads the status line and headers before it yields the response, with read_timeout restarting at every
58
+ # byte, so the deadline can't be checked there: a Timeout cuts the whole exchange off at total_timeout instead.
59
+ # The connection is dropped whichever way the block ends, so nothing is left half-used. The deadline is checked
60
+ # again at each chunk, which also stops a body that arrives in time but whose clock says otherwise.
61
+ def perform(body, headers, deadline)
62
+ request = Net::HTTP::Post.new(@endpoint.request_uri, headers)
63
+ request.body = body
64
+ http = connection
65
+ Timeout.timeout(@total_timeout, DeadlineExceeded, expired_message) do
66
+ http.start { http.request(request) { |response| return read(response, deadline) } }
67
+ end
68
+ end
69
+
70
+ def connection
71
+ Net::HTTP.new(@endpoint.hostname, @endpoint.port).tap do |http|
72
+ http.use_ssl = @endpoint.scheme == "https"
73
+ http.open_timeout = @open_timeout
74
+ http.read_timeout = http.write_timeout = @read_timeout
75
+ end
76
+ end
77
+
78
+ def read(response, deadline)
79
+ Reply.new(response.code, body_of(response, deadline), response["retry-after"])
80
+ end
81
+
82
+ # The whole body, within the limits. On a failure status a body that is compressed or over the cap is dropped
83
+ # rather than refused: the status alone says what kind of failure it is (a proxy's gzipped 401 is still a
84
+ # credentials problem, not a transport one), and nothing in the body is read for it anyway.
85
+ def body_of(response, deadline)
86
+ check_headers(response)
87
+ body = String.new(encoding: Encoding::BINARY)
88
+ response.read_body do |chunk|
89
+ expired! unless @clock.call < deadline
90
+ too_large! if body.bytesize + chunk.bytesize > @max_response_bytes
91
+ body << chunk
92
+ end
93
+ body
94
+ rescue ResponseTooLarge, UnexpectedEncoding
95
+ raise if response.code.start_with?("2")
96
+
97
+ String.new(encoding: Encoding::BINARY)
98
+ end
99
+
100
+ def check_headers(response)
101
+ encoding = response["content-encoding"].to_s.strip.downcase
102
+ unless encoding.empty? || encoding == "identity"
103
+ raise UnexpectedEncoding, "the response was compressed although the request asked for none"
104
+ end
105
+
106
+ length = response["content-length"].to_s.strip
107
+ too_large! if length.match?(/\A\d+\z/) && length.to_i > @max_response_bytes
108
+ end
109
+
110
+ def too_large!
111
+ raise ResponseTooLarge, "the response was larger than max_response_bytes (#{@max_response_bytes})"
112
+ end
113
+
114
+ def expired!
115
+ raise DeadlineExceeded, expired_message, cause: nil
116
+ end
117
+
118
+ def expired_message
119
+ "the request ran past total_timeout (#{@total_timeout}s)"
120
+ end
121
+ end
122
+ end
123
+ end
@@ -12,9 +12,16 @@ module Squishling
12
12
  # The ruby_llm gem isn't installed (Adapter.detect tells that apart from a gem that is, but too old).
13
13
  class MissingGemError < ConfigurationError; end
14
14
 
15
- # Raises ConfigurationError when the ruby_llm gem is missing.
16
- def initialize
17
- super
15
+ # Raises ConfigurationError when the ruby_llm gem is missing. keep_cause: true keeps RubyLLM's original exception
16
+ # as the #cause of the errors raised for a failed request (its message and response hold the provider's response
17
+ # body); by default #cause is a sanitized stand-in (Adapter::ProviderFailure) with only the class and HTTP status.
18
+ def initialize(keep_cause: false)
19
+ super()
20
+ unless [true, false].include?(keep_cause)
21
+ raise ConfigurationError, "adapter :ruby_llm keep_cause: must be true or false, got #{keep_cause.inspect}"
22
+ end
23
+
24
+ @keep_cause = keep_cause
18
25
  require "ruby_llm"
19
26
  check_version!
20
27
  @lib = gem_lib_dir
@@ -123,31 +130,60 @@ module Squishling
123
130
  end
124
131
 
125
132
  # Transient HTTP failures are already retried by RubyLLM; anything that still fails is surfaced as an
126
- # LLMError, which moves on to the next attempt of the escalation (or propagates from the last one). A 400
127
- # means the request we built is invalid (an unsupported param, a schema the provider rejects), so it's a
128
- # setup mistake: escalating or a fallback would otherwise hide it on every call.
133
+ # LLMError, which moves on to the next attempt of the escalation (or propagates from the last one), or as a
134
+ # ConfigurationError for a setup mistake (see #http_failure for which statuses are which): escalating or a
135
+ # fallback would otherwise hide it on every call.
129
136
  # A provider's or transport's own message never goes into ours: a provider can echo request content or part of
130
- # an API key in it, and ours reach logs and error trackers. The original stays as #cause for whoever
131
- # chooses to read it. RubyLLM's own ConfigurationError is local (it names a missing setting), so it is kept.
137
+ # an API key in it, and ours reach logs and error trackers.
138
+ # Error trackers report the whole cause chain, and RubyLLM's error message and response hold the provider's
139
+ # response body, so #cause is a sanitized stand-in (class, HTTP status, backtrace); the original is there only
140
+ # with keep_cause: true. RubyLLM's own ConfigurationError, and the errors #build_chat, #apply_params, and #judge
141
+ # convert, are local (they name a missing setting or a bad param and hold no provider response), so they are
142
+ # raised as they are and Ruby sets the original as their #cause.
132
143
  def guarded(request)
133
144
  yield
134
145
  rescue ::RubyLLM::ConfigurationError => e
135
146
  raise configuration_error(request, "#{e.class}: #{e.message}")
136
- rescue ::RubyLLM::UnauthorizedError, ::RubyLLM::ForbiddenError => e
137
- raise configuration_error(request, describe_failure(e))
138
- rescue ::RubyLLM::BadRequestError => e
139
- raise rejected_request_error(request, via: describe_failure(e), body: response_field(e.response, :body),
140
- http_status: response_field(e.response, :status))
141
147
  rescue ::RubyLLM::Error, Faraday::Error => e
142
- raise llm_error(request, describe_failure(e))
148
+ raise failure_for(request, e), cause: cause_of(e)
149
+ end
150
+
151
+ # Sorted by the HTTP status, not by RubyLLM's error class: RubyLLM's middleware picks a class from the provider's
152
+ # message text on a 400, so a bad parameter whose message happens to say "per day" arrives as a RateLimitError.
153
+ # An error with no response is sorted by its class. The one class honored over the status is RubyLLM's
154
+ # ContextLengthExceededError, since RubyLLM is where its provider's wording for an overlong prompt is known.
155
+ def failure_for(request, error)
156
+ via = describe_failure(error)
157
+ return llm_error(request, via) if error.is_a?(::RubyLLM::ContextLengthExceededError)
158
+
159
+ status = response_field(error.response, :status) || class_status(error)
160
+ http_failure(request, status:, body: response_field(error.response, :body), via:)
161
+ end
162
+
163
+ # The status RubyLLM's classes stand for, for an error raised without a response.
164
+ def class_status(error)
165
+ case error
166
+ when ::RubyLLM::UnauthorizedError then 401
167
+ when ::RubyLLM::PaymentRequiredError then 402
168
+ when ::RubyLLM::ForbiddenError then 403
169
+ when ::RubyLLM::BadRequestError then 400
170
+ end
143
171
  end
144
172
 
145
173
  # The error's class and HTTP status, when it has one, which is all of it that is ours to repeat.
146
174
  def describe_failure(error)
147
- status = response_field(error.response, :status)
175
+ status = status_of(error)
148
176
  status ? "#{error.class}, HTTP #{status}" : error.class.to_s
149
177
  end
150
178
 
179
+ def status_of(error)
180
+ response_field(error.response, :status)
181
+ end
182
+
183
+ def cause_of(error)
184
+ @keep_cause ? error : sanitized_cause(error, status: status_of(error))
185
+ end
186
+
151
187
  # A field of an error's response, which is a Faraday::Response, or a Hash for a Faraday error.
152
188
  def response_field(response, field)
153
189
  response.is_a?(Hash) ? response[field] : (response.public_send(field) if response.respond_to?(field))
@@ -22,8 +22,8 @@ module Squishling
22
22
  # max_total_output_tokens:/max_total_input_tokens: cap the tokens one call may use across all its requests
23
23
  # (see Budget); false lifts a cap inherited from the parent class or the configuration:
24
24
  # squishling max_total_output_tokens: 25_000
25
- # squawk: is called after each LLM attempt (narrowed by squawk_on:) with the raw output (see
26
- # Configuration#squawk); false silences
25
+ # squawk: is called after each LLM attempt (narrowed by squawk_on:), and once more when a token budget or a
26
+ # disagreement stops the call, with the raw output (see Configuration#squawk); false silences
27
27
  # an inherited one:
28
28
  # squishling squawk: ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
29
29
  # squawk_on: limits which attempts reach the hook: :all (the default), :rejections, :retries, :final, or a