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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -0
- data/README.md +3 -3
- data/docs/adapters.md +86 -18
- data/docs/configuration.md +3 -3
- data/docs/failures.md +55 -12
- data/docs/harnesses.md +15 -1
- data/docs/measuring-tokens.md +4 -0
- data/docs/observers.md +3 -3
- data/docs/routing.md +9 -3
- data/docs/schemas.md +13 -0
- data/docs/testing.md +7 -3
- data/docs/verifying.md +4 -2
- data/lib/squishling/adapter/error_body.rb +9 -2
- data/lib/squishling/adapter.rb +62 -9
- data/lib/squishling/adapters/openai_compatible.rb +58 -51
- data/lib/squishling/adapters/openai_options.rb +45 -3
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +51 -15
- data/lib/squishling/class_methods.rb +2 -2
- data/lib/squishling/configuration.rb +2 -1
- data/lib/squishling/errors.rb +7 -5
- data/lib/squishling/invoker.rb +24 -6
- data/lib/squishling/observer.rb +2 -2
- data/lib/squishling/params.rb +2 -2
- data/lib/squishling/result.rb +13 -0
- data/lib/squishling/router.rb +3 -2
- data/lib/squishling/run/reports.rb +63 -0
- data/lib/squishling/run.rb +5 -22
- data/lib/squishling/schema.rb +44 -4
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +7 -4
- data/lib/squishling/testing/state.rb +2 -1
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling/wrapper.rb +19 -1
- data/lib/squishling.rb +3 -0
- metadata +4 -1
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=`)
|
|
31
|
-
|
|
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
|
-
|
|
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
|
data/lib/squishling/adapter.rb
CHANGED
|
@@ -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
|
|
88
|
-
# echo request content or part of a key.
|
|
89
|
-
#
|
|
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
|
-
#
|
|
130
|
-
# the
|
|
131
|
-
#
|
|
132
|
-
#
|
|
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
|
|
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
|
-
|
|
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.
|
|
35
|
-
#
|
|
36
|
-
|
|
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.
|
|
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
|
|
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
|
-
[
|
|
169
|
+
[@transport.post(body, headers), nil]
|
|
157
170
|
rescue *NETWORK_ERRORS => e
|
|
158
171
|
[nil, e]
|
|
159
172
|
end
|
|
160
173
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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&.
|
|
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?(
|
|
91
|
+
def number(name, value, type: Numeric)
|
|
92
|
+
return value if value.is_a?(type) && value.positive?
|
|
52
93
|
|
|
53
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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)
|
|
127
|
-
#
|
|
128
|
-
#
|
|
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.
|
|
131
|
-
#
|
|
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
|
|
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 =
|
|
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:)
|
|
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
|