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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +25 -9
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +53 -18
  6. data/docs/failures.md +123 -18
  7. data/docs/harnesses.md +24 -14
  8. data/docs/measuring-tokens.md +8 -6
  9. data/docs/routing.md +5 -0
  10. data/docs/schemas.md +11 -2
  11. data/docs/testing.md +183 -0
  12. data/docs/verifying.md +84 -0
  13. data/lib/squishling/adapter/error_body.rb +47 -0
  14. data/lib/squishling/adapter.rb +275 -0
  15. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  16. data/lib/squishling/adapters/openai_options.rb +72 -0
  17. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  18. data/lib/squishling/budget.rb +71 -0
  19. data/lib/squishling/class_methods.rb +43 -7
  20. data/lib/squishling/class_registry.rb +63 -0
  21. data/lib/squishling/configuration.rb +57 -6
  22. data/lib/squishling/definition.rb +90 -16
  23. data/lib/squishling/errors.rb +92 -7
  24. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  25. data/lib/squishling/harness/option_types.rb +2 -1
  26. data/lib/squishling/harness/registry.rb +8 -36
  27. data/lib/squishling/invoker.rb +21 -9
  28. data/lib/squishling/judge.rb +16 -3
  29. data/lib/squishling/llm_client.rb +66 -132
  30. data/lib/squishling/model_path.rb +60 -13
  31. data/lib/squishling/named_registry.rb +64 -0
  32. data/lib/squishling/observer.rb +11 -2
  33. data/lib/squishling/output_check.rb +13 -9
  34. data/lib/squishling/params.rb +6 -23
  35. data/lib/squishling/railtie.rb +10 -0
  36. data/lib/squishling/result.rb +37 -16
  37. data/lib/squishling/rotation.rb +5 -0
  38. data/lib/squishling/rspec.rb +18 -0
  39. data/lib/squishling/run/messages.rb +40 -3
  40. data/lib/squishling/run/rejections.rb +43 -0
  41. data/lib/squishling/run.rb +66 -42
  42. data/lib/squishling/schema.rb +10 -9
  43. data/lib/squishling/squawk.rb +33 -3
  44. data/lib/squishling/tasks/squishling.rake +18 -0
  45. data/lib/squishling/testing/adapter.rb +194 -0
  46. data/lib/squishling/testing/checks.rb +39 -0
  47. data/lib/squishling/testing/direct.rb +41 -0
  48. data/lib/squishling/testing/errors.rb +18 -0
  49. data/lib/squishling/testing/fixtures.rb +206 -0
  50. data/lib/squishling/testing/helpers.rb +71 -0
  51. data/lib/squishling/testing/minitest.rb +32 -0
  52. data/lib/squishling/testing/observer.rb +31 -0
  53. data/lib/squishling/testing/reply.rb +60 -0
  54. data/lib/squishling/testing/request.rb +75 -0
  55. data/lib/squishling/testing/rspec.rb +30 -0
  56. data/lib/squishling/testing/script.rb +189 -0
  57. data/lib/squishling/testing/state.rb +73 -0
  58. data/lib/squishling/testing/stubs.rb +112 -0
  59. data/lib/squishling/testing.rb +126 -0
  60. data/lib/squishling/verification.rb +39 -0
  61. data/lib/squishling/verifier.rb +179 -0
  62. data/lib/squishling/version.rb +1 -1
  63. data/lib/squishling.rb +57 -4
  64. 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