squishling 0.4.0 → 0.5.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/CHANGELOG.md +75 -0
- data/README.md +25 -9
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +53 -18
- data/docs/failures.md +123 -18
- data/docs/harnesses.md +24 -14
- data/docs/measuring-tokens.md +8 -6
- data/docs/routing.md +5 -0
- data/docs/schemas.md +11 -2
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +57 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +92 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +21 -9
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +11 -2
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +6 -23
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +66 -42
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +57 -4
- metadata +40 -21
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module Squishling
|
|
10
|
+
module Adapters
|
|
11
|
+
# Sends requests to any server that speaks OpenAI's Chat Completions API (POST {base_url}/chat/completions):
|
|
12
|
+
# OpenAI itself, Ollama, vLLM, LM Studio, llama.cpp, Together, Groq, and the like. Plain Net::HTTP, so it needs
|
|
13
|
+
# no gem beyond Squishling's own. Squishling asks for strict structured output (response_format: json_schema);
|
|
14
|
+
# servers that lack it can use structured_output: :json_object or :none, where the schema goes into the system
|
|
15
|
+
# prompt instead, and Squishling's own validation still decides what is accepted. See docs/adapters.md.
|
|
16
|
+
#
|
|
17
|
+
# Squishling.configure do |config|
|
|
18
|
+
# config.adapter = { type: :openai_compatible, base_url: "http://localhost:11434/v1", default_model: "llama3.2" }
|
|
19
|
+
# end
|
|
20
|
+
class OpenAICompatible < Adapter::Base
|
|
21
|
+
Options = OpenAIOptions
|
|
22
|
+
private_constant :Options
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
25
|
+
STRUCTURED_OUTPUT = %i[json_schema json_object none].freeze
|
|
26
|
+
TOKEN_PARAMS = %i[max_completion_tokens max_tokens].freeze
|
|
27
|
+
# Params Squishling maps itself; the rest go into the request body as given.
|
|
28
|
+
PORTABLE_PARAMS = %i[temperature thinking max_output_tokens].freeze
|
|
29
|
+
# Failures worth another try inside the adapter: timeouts, connection and protocol trouble (below), HTTP 408,
|
|
30
|
+
# 429, and 5xx. A timeout is retried even though the server may have processed the request.
|
|
31
|
+
NETWORK_ERRORS = [Timeout::Error, SocketError, EOFError, IOError, SystemCallError, Net::ProtocolError,
|
|
32
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, OpenSSL::OpenSSLError, Zlib::Error].freeze
|
|
33
|
+
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
|
|
37
|
+
private_constant :STRUCTURED_OUTPUT, :TOKEN_PARAMS, :PORTABLE_PARAMS, :NETWORK_ERRORS, :MAX_RETRY_DELAY,
|
|
38
|
+
:CONTEXT_LENGTH_MESSAGE
|
|
39
|
+
|
|
40
|
+
# base_url: the API root, ending where /chat/completions starts. api_key: a String, or a callable that returns
|
|
41
|
+
# one; for OpenAI's own endpoint it defaults to ENV["OPENAI_API_KEY"] (read when a request is made). Any other
|
|
42
|
+
# endpoint gets only the key you give it, so an OpenAI key can't reach another host. None is sent when the key
|
|
43
|
+
# is blank (local servers). headers: extra request headers. default_model: used for steps that name no model.
|
|
44
|
+
# max_retries: how often a timeout, connection failure, HTTP 408, 429, or 5xx is retried here (honoring
|
|
45
|
+
# Retry-After) before it surfaces as an LLMError. max_tokens_param: the body key for max_output_tokens
|
|
46
|
+
# (:max_completion_tokens, or :max_tokens for older servers). structured_output: :json_schema, :json_object,
|
|
47
|
+
# or :none. Raises ConfigurationError for a bad option.
|
|
48
|
+
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)
|
|
50
|
+
super()
|
|
51
|
+
@endpoint = Options.endpoint(base_url)
|
|
52
|
+
@api_key = api_key
|
|
53
|
+
@headers = Options.headers(headers)
|
|
54
|
+
@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
|
+
@max_retries = Options.retries(max_retries)
|
|
58
|
+
@max_tokens_param = Options.choice(:max_tokens_param, max_tokens_param, TOKEN_PARAMS)
|
|
59
|
+
@structured_output = Options.choice(:structured_output, structured_output, STRUCTURED_OUTPUT)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 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).
|
|
64
|
+
def inspect
|
|
65
|
+
"#<#{self.class.name} #{@endpoint.scheme}://#{@endpoint.authority}#{@endpoint.path} " \
|
|
66
|
+
"default_model=#{@default_model.inspect} structured_output=#{@structured_output.inspect}>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def complete(request)
|
|
70
|
+
model = request.model || @default_model
|
|
71
|
+
unless model
|
|
72
|
+
raise configuration_error(request,
|
|
73
|
+
"no model: name one with model: or give the adapter a default_model:")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
data = post(request, JSON.generate(body(request, model)), request_headers(request))
|
|
77
|
+
Adapter::Response.new(content: content_of(data), model: data["model"] || model,
|
|
78
|
+
tokens: tokens_of(data["usage"]), finish_reason: finish_reason_of(data))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Params first, then what Squishling owns, so a param can never replace the model, the conversation, or the
|
|
84
|
+
# strict output format. n and stream are fixed: one non-streamed completion.
|
|
85
|
+
def body(request, model)
|
|
86
|
+
params = request.params
|
|
87
|
+
body = params.except(*PORTABLE_PARAMS).transform_keys(&:to_s)
|
|
88
|
+
body["temperature"] = params[:temperature] if params.key?(:temperature)
|
|
89
|
+
body[@max_tokens_param.to_s] = params[:max_output_tokens] if params.key?(:max_output_tokens)
|
|
90
|
+
body.merge!(reasoning(request, params[:thinking]))
|
|
91
|
+
body.merge("model" => model, "messages" => messages(request), "n" => 1, "stream" => false)
|
|
92
|
+
.merge(response_format(request))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Chat Completions only has reasoning_effort: true and false have no equivalent, so they leave the default.
|
|
96
|
+
def reasoning(request, thinking)
|
|
97
|
+
return {} unless thinking.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
unsupported = thinking.keys - [:effort]
|
|
100
|
+
unless unsupported.empty?
|
|
101
|
+
raise configuration_error(request, "thinking #{unsupported.join(', ')} isn't supported by this adapter " \
|
|
102
|
+
"(only thinking: { effort: })")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
{ "reasoning_effort" => thinking[:effort].to_s }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def messages(request)
|
|
109
|
+
system = system_prompt(request)
|
|
110
|
+
turns = request.messages.map { |message| { "role" => message[:role].to_s, "content" => message[:content] } }
|
|
111
|
+
system ? [{ "role" => "system", "content" => system }] + turns : turns
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Without response_format: json_schema the model has to be told the schema in words.
|
|
115
|
+
def system_prompt(request)
|
|
116
|
+
return request.instructions if @structured_output == :json_schema
|
|
117
|
+
|
|
118
|
+
schema = JSON.pretty_generate(request.schema["schema"])
|
|
119
|
+
[request.instructions,
|
|
120
|
+
"Respond only with JSON that validates against this JSON Schema:\n#{schema}"].compact.join("\n\n")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def response_format(request)
|
|
124
|
+
case @structured_output
|
|
125
|
+
when :json_schema then { "response_format" => { "type" => "json_schema",
|
|
126
|
+
"json_schema" => json_schema(request) } }
|
|
127
|
+
when :json_object then { "response_format" => { "type" => "json_object" } }
|
|
128
|
+
else {}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def json_schema(request)
|
|
133
|
+
schema = request.schema
|
|
134
|
+
name = schema["name"].to_s.gsub(/[^A-Za-z0-9_-]/, "_")[0, 64]
|
|
135
|
+
{ "name" => name.empty? ? "response" : name, "description" => schema["description"],
|
|
136
|
+
"schema" => schema["schema"], "strict" => true }.compact
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# One request, retried here for failures worth retrying. A refusal or an empty answer is returned as empty
|
|
140
|
+
# content, which Squishling reports as invalid output.
|
|
141
|
+
def post(request, body, headers)
|
|
142
|
+
attempt = 0
|
|
143
|
+
loop do
|
|
144
|
+
attempt += 1
|
|
145
|
+
response, error = exchange(body, headers)
|
|
146
|
+
return parse_body(request, response) if response.is_a?(Net::HTTPSuccess)
|
|
147
|
+
|
|
148
|
+
failure = failure_for(request, response, error)
|
|
149
|
+
raise failure, cause: error unless retryable?(response, error) && attempt <= @max_retries
|
|
150
|
+
|
|
151
|
+
pause(retry_delay(response, attempt))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def exchange(body, headers)
|
|
156
|
+
[http_post(body, headers), nil]
|
|
157
|
+
rescue *NETWORK_ERRORS => e
|
|
158
|
+
[nil, e]
|
|
159
|
+
end
|
|
160
|
+
|
|
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)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Built once per call, before any request and outside the network-error rescue, so the developer's own api_key
|
|
174
|
+
# callable runs once and its exceptions propagate as they are.
|
|
175
|
+
def request_headers(request)
|
|
176
|
+
key = (@api_key.respond_to?(:call) ? @api_key.call : (@api_key || default_api_key)).to_s.strip
|
|
177
|
+
if Options.unsafe_header?(key)
|
|
178
|
+
raise configuration_error(request,
|
|
179
|
+
"the api_key can't contain line breaks or NUL")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
headers = { "Content-Type" => "application/json", "Accept" => "application/json",
|
|
183
|
+
"User-Agent" => "squishling/#{VERSION}" }
|
|
184
|
+
headers["Authorization"] = "Bearer #{key}" unless key.empty?
|
|
185
|
+
headers.merge(@headers)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def default_api_key
|
|
189
|
+
default = URI.parse(DEFAULT_BASE_URL)
|
|
190
|
+
return unless @endpoint.scheme == default.scheme && @endpoint.hostname.downcase == default.hostname
|
|
191
|
+
|
|
192
|
+
ENV.fetch("OPENAI_API_KEY", nil)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_body(request, response)
|
|
196
|
+
data = JSON.parse(response.body.to_s.dup.force_encoding(Encoding::UTF_8))
|
|
197
|
+
return data if data.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
raise llm_error(request, "the response wasn't a JSON object (HTTP #{response.code})")
|
|
200
|
+
rescue JSON::ParserError
|
|
201
|
+
raise llm_error(request, "the response wasn't valid JSON (HTTP #{response.code})")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def retryable?(response, error)
|
|
205
|
+
return !certificate_failure?(error) if error
|
|
206
|
+
|
|
207
|
+
status = response.code.to_i
|
|
208
|
+
[408, 429].include?(status) || status >= 500
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The provider's own message is never repeated: only the class of failure or the HTTP status.
|
|
212
|
+
def failure_for(request, response, error)
|
|
213
|
+
return transport_failure(request, error) if error
|
|
214
|
+
|
|
215
|
+
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)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# A certificate that doesn't verify is a setup mistake (base_url, CA store) that a retry can't fix.
|
|
222
|
+
def transport_failure(request, error)
|
|
223
|
+
if certificate_failure?(error)
|
|
224
|
+
return configuration_error(request, "TLS certificate verification failed (#{error.class}); check base_url " \
|
|
225
|
+
"and the CA certificates")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
llm_error(request, "the request failed (#{error.class})")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def certificate_failure?(error)
|
|
232
|
+
error.is_a?(OpenSSL::SSL::SSLError) && error.message.include?("certificate verify failed")
|
|
233
|
+
end
|
|
234
|
+
|
|
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))
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def retry_delay(response, attempt)
|
|
260
|
+
after = response&.[]("retry-after").to_s
|
|
261
|
+
seconds = after.match?(/\A\d+\z/) ? after.to_i : (0.5 * (2**(attempt - 1))) + (rand * 0.25)
|
|
262
|
+
[seconds, MAX_RETRY_DELAY].min
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def pause(seconds)
|
|
266
|
+
sleep(seconds)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def content_of(data)
|
|
270
|
+
message = Array(data["choices"]).first
|
|
271
|
+
message = message["message"] if message.is_a?(Hash)
|
|
272
|
+
content = message.is_a?(Hash) ? message["content"] : nil
|
|
273
|
+
content.is_a?(Array) ? content.filter_map { |part| part["text"] if part.is_a?(Hash) }.join : content
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The first choice's finish_reason, in the vocabulary of Adapter::Response ("length" is the output cap).
|
|
277
|
+
def finish_reason_of(data)
|
|
278
|
+
choice = Array(data["choices"]).first
|
|
279
|
+
reason = choice["finish_reason"] if choice.is_a?(Hash)
|
|
280
|
+
return unless reason.is_a?(String)
|
|
281
|
+
|
|
282
|
+
reason == "length" ? :max_tokens : reason
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def tokens_of(usage)
|
|
286
|
+
return {} unless usage.is_a?(Hash)
|
|
287
|
+
|
|
288
|
+
{
|
|
289
|
+
input_tokens: usage["prompt_tokens"],
|
|
290
|
+
output_tokens: usage["completion_tokens"],
|
|
291
|
+
cache_read_tokens: detail(usage, "prompt_tokens_details", "cached_tokens"),
|
|
292
|
+
thinking_tokens: detail(usage, "completion_tokens_details", "reasoning_tokens")
|
|
293
|
+
}.select { |_, count| count.is_a?(Integer) }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# A count inside a usage sub-object, which a server might send in any shape.
|
|
297
|
+
def detail(usage, group, key)
|
|
298
|
+
usage[group][key] if usage[group].is_a?(Hash)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
Adapter::Registry.register_built_in(:openai_compatible, OpenAICompatible)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Squishling
|
|
6
|
+
module Adapters
|
|
7
|
+
# Checks the options of Adapters::OpenAICompatible, raising ConfigurationError (without echoing a value that may
|
|
8
|
+
# be a secret) for a bad one.
|
|
9
|
+
module OpenAIOptions
|
|
10
|
+
# The URL itself is never in the message: it may carry credentials.
|
|
11
|
+
INVALID_BASE_URL = "adapter :openai_compatible base_url must be an http(s) URL without credentials (use " \
|
|
12
|
+
"api_key:), a query, or a fragment"
|
|
13
|
+
INVALID_HEADER = "adapter :openai_compatible headers (and the api_key) can't contain line breaks or NUL"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def endpoint(base_url)
|
|
18
|
+
uri = URI.parse(base_url.to_s.strip.sub(%r{/+\z}, ""))
|
|
19
|
+
unless uri.is_a?(URI::HTTP) && uri.host && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
|
|
20
|
+
raise ConfigurationError, INVALID_BASE_URL
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
URI.parse("#{uri}/chat/completions")
|
|
24
|
+
rescue URI::InvalidURIError
|
|
25
|
+
raise ConfigurationError, INVALID_BASE_URL
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def headers(headers)
|
|
29
|
+
unless headers.is_a?(Hash) && headers.all? { |name, value| name.is_a?(String) && value.is_a?(String) }
|
|
30
|
+
raise ConfigurationError, "adapter :openai_compatible headers must be a Hash of String names to String values"
|
|
31
|
+
end
|
|
32
|
+
raise ConfigurationError, INVALID_HEADER if headers.flatten.any? { |text| unsafe_header?(text) }
|
|
33
|
+
|
|
34
|
+
# A copy, so the caller can't change a header after the check above.
|
|
35
|
+
headers.to_h { |name, value| [name.dup.freeze, value.dup.freeze] }.freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Net::HTTP raises, with the value in the message, for a header that carries a line break.
|
|
39
|
+
def unsafe_header?(text)
|
|
40
|
+
text.match?(/[\r\n\0]/)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def default_model(model)
|
|
44
|
+
return nil if model.nil?
|
|
45
|
+
return model.to_s if (model.is_a?(String) || model.is_a?(Symbol)) && !model.to_s.strip.empty?
|
|
46
|
+
|
|
47
|
+
raise ConfigurationError, "adapter :openai_compatible default_model must be a model name, got #{model.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def number(name, value)
|
|
51
|
+
return value if value.is_a?(Numeric) && value.positive?
|
|
52
|
+
|
|
53
|
+
raise ConfigurationError, "adapter :openai_compatible #{name} must be a positive number, got #{value.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def retries(value)
|
|
57
|
+
return value if value.is_a?(Integer) && !value.negative?
|
|
58
|
+
|
|
59
|
+
raise ConfigurationError,
|
|
60
|
+
"adapter :openai_compatible max_retries must be an Integer of 0 or more, got #{value.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def choice(name, value, choices)
|
|
64
|
+
symbol = value.is_a?(String) ? value.to_sym : value
|
|
65
|
+
return symbol if choices.include?(symbol)
|
|
66
|
+
|
|
67
|
+
raise ConfigurationError,
|
|
68
|
+
"adapter :openai_compatible #{name} must be one of #{choices.join(', ')}, got #{value.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Adapters
|
|
5
|
+
# Sends requests through RubyLLM (https://rubyllm.com): OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock,
|
|
6
|
+
# OpenRouter, and any other provider it supports. Configure provider API keys in RubyLLM as usual
|
|
7
|
+
# (RubyLLM.configure); transient HTTP failures are retried by RubyLLM itself (RubyLLM.config.max_retries).
|
|
8
|
+
# The ruby_llm gem is loaded when the adapter is built, so Squishling doesn't depend on it. See docs/adapters.md.
|
|
9
|
+
class RubyLLM < Adapter::Base
|
|
10
|
+
MINIMUM_VERSION = Gem::Version.new("2.1")
|
|
11
|
+
|
|
12
|
+
# The ruby_llm gem isn't installed (Adapter.detect tells that apart from a gem that is, but too old).
|
|
13
|
+
class MissingGemError < ConfigurationError; end
|
|
14
|
+
|
|
15
|
+
# Raises ConfigurationError when the ruby_llm gem is missing.
|
|
16
|
+
def initialize
|
|
17
|
+
super
|
|
18
|
+
require "ruby_llm"
|
|
19
|
+
check_version!
|
|
20
|
+
@lib = gem_lib_dir
|
|
21
|
+
rescue LoadError => e
|
|
22
|
+
raise unless e.path == "ruby_llm"
|
|
23
|
+
|
|
24
|
+
raise MissingGemError, "adapter :ruby_llm needs the ruby_llm gem; add `gem \"ruby_llm\"` to your Gemfile"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# One chat on the request's model: the system prompt, the strict output schema, and the params, then the
|
|
28
|
+
# conversation so far replayed, answering its last message. Models missing from RubyLLM's registry (e.g. newly
|
|
29
|
+
# released ones) are only usable when a provider is named, so RubyLLM is told to assume they exist.
|
|
30
|
+
def complete(request)
|
|
31
|
+
chat = build_chat(request)
|
|
32
|
+
*history, last = request.messages
|
|
33
|
+
history.each { |message| chat.add_message(role: message[:role], content: message[:content]) }
|
|
34
|
+
response = guarded(request) { chat.ask(last[:content]) }
|
|
35
|
+
Adapter::Response.new(content: response.content, model: response.model, tokens: tokens_of(response),
|
|
36
|
+
finish_reason: response.finish_reason)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def judgments?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A System One judgment (RubyLLM.judge) on one step. The step's params are sent as provider options.
|
|
44
|
+
# RubyLLM raises ArgumentError for a judgment it can't build (e.g. a model on a provider that takes none, or
|
|
45
|
+
# a provider option its protocol reserves), and a bare RubyLLM::Error without an HTTP response for a provider
|
|
46
|
+
# that doesn't support judgments at all. An ArgumentError from anywhere else (an instrumentation subscriber
|
|
47
|
+
# runs inside the call) is the developer's own and propagates as-is.
|
|
48
|
+
def judge(input, questions:, step:, label:)
|
|
49
|
+
request = judgment_request(label, step)
|
|
50
|
+
guarded(request) do
|
|
51
|
+
::RubyLLM.judge(input, questions:, provider_options: step.params, **model_options(step.model, step.provider))
|
|
52
|
+
rescue ::RubyLLM::ModelNotFoundError, ::RubyLLM::Error, ArgumentError => e
|
|
53
|
+
raise unless setup_failure?(e)
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError, "#{label}: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# RubyLLM's instrumentation context (the workflow and its captured context) is carried to the worker threads
|
|
60
|
+
# the way RubyLLM's own concurrent tool calls do it. Unlike those, the threads don't enter the Rails executor:
|
|
61
|
+
# the caller already holds it, and a second share can deadlock against a pending code reload.
|
|
62
|
+
def capture_context
|
|
63
|
+
instrumentation = ::RubyLLM::Support::Instrumentation if defined?(::RubyLLM::Support::Instrumentation)
|
|
64
|
+
return unless instrumentation.respond_to?(:current_workflow) && instrumentation.respond_to?(:capture_context)
|
|
65
|
+
|
|
66
|
+
[instrumentation.current_workflow, instrumentation.capture_context]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def with_context(context, &)
|
|
70
|
+
return yield unless context
|
|
71
|
+
|
|
72
|
+
instrumentation = ::RubyLLM::Support::Instrumentation
|
|
73
|
+
instrumentation.with_workflow(context.first) { instrumentation.with_context(context.last, &) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def tokens_of(response)
|
|
79
|
+
response.tokens ? response.tokens.to_h : {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether a failure from RubyLLM.judge is a judgment RubyLLM can't make, rather than a failed request or the
|
|
83
|
+
# developer's own error.
|
|
84
|
+
def setup_failure?(error)
|
|
85
|
+
case error
|
|
86
|
+
when ::RubyLLM::ModelNotFoundError then true
|
|
87
|
+
when ArgumentError then raised_by_ruby_llm?(error)
|
|
88
|
+
else error.instance_of?(::RubyLLM::Error) && error.response.nil?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def judgment_request(label, step)
|
|
93
|
+
Adapter::Request.new(label:, model: step.model, provider: step.provider, instructions: nil, messages: [],
|
|
94
|
+
schema: nil, params: step.params)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_chat(request)
|
|
98
|
+
chat = ::RubyLLM.chat(**model_options(request.model, request.provider))
|
|
99
|
+
chat.with_instructions(request.instructions)
|
|
100
|
+
chat.with_schema(request.schema)
|
|
101
|
+
apply_params(chat, request)
|
|
102
|
+
chat
|
|
103
|
+
rescue ::RubyLLM::ModelNotFoundError, ::RubyLLM::ConfigurationError => e
|
|
104
|
+
raise configuration_error(request, e.message)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
|
|
108
|
+
# and raises ArgumentError before any request is sent.
|
|
109
|
+
def apply_params(chat, request)
|
|
110
|
+
params = request.params
|
|
111
|
+
rest = params.except(:temperature, :thinking, :max_output_tokens)
|
|
112
|
+
chat.with_temperature(params[:temperature]) if params.key?(:temperature)
|
|
113
|
+
chat.with_max_output_tokens(params[:max_output_tokens]) if params.key?(:max_output_tokens)
|
|
114
|
+
apply_thinking(chat, params[:thinking]) if params.key?(:thinking)
|
|
115
|
+
chat.with_provider_options(rest) if rest.any?
|
|
116
|
+
rescue ArgumentError => e
|
|
117
|
+
raise configuration_error(request, "invalid params #{params.keys.inspect} (#{e.message})")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# true: the model's default thinking; false: off; a Hash: { effort:, budget:, display: }.
|
|
121
|
+
def apply_thinking(chat, thinking)
|
|
122
|
+
thinking.is_a?(Hash) ? chat.with_thinking(**thinking) : chat.with_thinking(thinking)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 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.
|
|
129
|
+
# 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.
|
|
132
|
+
def guarded(request)
|
|
133
|
+
yield
|
|
134
|
+
rescue ::RubyLLM::ConfigurationError => e
|
|
135
|
+
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
|
+
rescue ::RubyLLM::Error, Faraday::Error => e
|
|
142
|
+
raise llm_error(request, describe_failure(e))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The error's class and HTTP status, when it has one, which is all of it that is ours to repeat.
|
|
146
|
+
def describe_failure(error)
|
|
147
|
+
status = response_field(error.response, :status)
|
|
148
|
+
status ? "#{error.class}, HTTP #{status}" : error.class.to_s
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A field of an error's response, which is a Faraday::Response, or a Hash for a Faraday error.
|
|
152
|
+
def response_field(response, field)
|
|
153
|
+
response.is_a?(Hash) ? response[field] : (response.public_send(field) if response.respond_to?(field))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def model_options(model, provider)
|
|
157
|
+
options = { model:, provider: }.compact
|
|
158
|
+
options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
|
|
159
|
+
options
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def known_model?(model, provider)
|
|
163
|
+
::RubyLLM.models.find(model, provider:)
|
|
164
|
+
true
|
|
165
|
+
rescue ::RubyLLM::ModelNotFoundError
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def raised_by_ruby_llm?(error)
|
|
170
|
+
error.backtrace&.first&.start_with?("#{@lib}/") || false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The adapter uses RubyLLM 2.1 APIs (judgments, with_max_output_tokens, Tokens#to_h), and no gemspec pins it.
|
|
174
|
+
def check_version!
|
|
175
|
+
version = Gem.loaded_specs["ruby_llm"]&.version
|
|
176
|
+
return if version.nil? || version >= MINIMUM_VERSION
|
|
177
|
+
|
|
178
|
+
raise ConfigurationError, "adapter :ruby_llm needs ruby_llm #{MINIMUM_VERSION} or newer, got #{version}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Where RubyLLM's own code lives, to tell its ArgumentErrors from the developer's (see #judge).
|
|
182
|
+
# It is read from the file that was loaded, which is the path RubyLLM's backtrace frames carry.
|
|
183
|
+
def gem_lib_dir
|
|
184
|
+
entry = $LOADED_FEATURES.find { |feature| feature.end_with?("/lib/ruby_llm.rb") }
|
|
185
|
+
return File.dirname(entry) if entry
|
|
186
|
+
|
|
187
|
+
File.join(Gem.loaded_specs.fetch("ruby_llm").full_gem_path, "lib")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
Adapter::Registry.register_built_in(:ruby_llm, RubyLLM)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# A cap on what one squished call may spend across every request it makes: max_total_output_tokens: and
|
|
5
|
+
# max_total_input_tokens:, each a positive Integer. A budget is a Hash of the limits one level declares
|
|
6
|
+
# ({ output:, input: }), layered config -> class -> method -> squish! like generation params. `false` lifts a limit an
|
|
7
|
+
# outer level set. See docs/failures.md.
|
|
8
|
+
module Budget
|
|
9
|
+
# The limit, as declared, for each kind of token, and the counter in Tally#used it is checked against.
|
|
10
|
+
KEYS = { output: :max_total_output_tokens, input: :max_total_input_tokens }.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# One level's limits, validated. A limit left nil is not set at this level.
|
|
15
|
+
def build(label, output: nil, input: nil)
|
|
16
|
+
{ output:, input: }.compact.to_h { |kind, value| [kind, check(value, KEYS.fetch(kind), label)] }.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# value as is when it is a positive Integer, or false (no limit). Raises ConfigurationError otherwise.
|
|
20
|
+
def check(value, name, label)
|
|
21
|
+
return value if value == false || (value.is_a?(Integer) && value.positive?)
|
|
22
|
+
|
|
23
|
+
raise ConfigurationError,
|
|
24
|
+
"#{label}: #{name} must be a positive Integer (or false for no limit), got #{value.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The limits that apply: later levels override earlier ones limit by limit, and false removes a limit.
|
|
28
|
+
def resolve(*layers)
|
|
29
|
+
layers.compact.reduce({}) { |merged, layer| merged.merge(layer) }.select { |_kind, max| max }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# What one call has spent so far, counted from every response it receives. Requests of one round run on worker
|
|
33
|
+
# threads, so the totals are taken under a lock. A response without token counts adds nothing, so a provider that
|
|
34
|
+
# reports no usage never exhausts a budget.
|
|
35
|
+
class Tally
|
|
36
|
+
def initialize(budget)
|
|
37
|
+
@budget = budget
|
|
38
|
+
@used = { input_tokens: 0, output_tokens: 0 }
|
|
39
|
+
@lock = Mutex.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Counts a response's tokens: an Adapter::Response, or a judgment (see Observer.usage).
|
|
43
|
+
def add(response)
|
|
44
|
+
usage = Observer.usage(response)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
@used[:input_tokens] += count(usage.input_tokens)
|
|
47
|
+
@used[:output_tokens] += count(usage.output_tokens)
|
|
48
|
+
end
|
|
49
|
+
response
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The first limit that has been reached ({ limit:, max:, used: }, used being both totals), or nil.
|
|
53
|
+
def exhausted
|
|
54
|
+
@lock.synchronize do
|
|
55
|
+
@budget.each do |kind, max|
|
|
56
|
+
next if @used.fetch(:"#{kind}_tokens") < max
|
|
57
|
+
|
|
58
|
+
return { limit: KEYS.fetch(kind), max:, used: @used.dup }
|
|
59
|
+
end
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def count(tokens)
|
|
67
|
+
tokens.is_a?(Integer) && tokens.positive? ? tokens : 0
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|