squishling 0.4.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +26 -10
  4. data/docs/adapters.md +257 -0
  5. data/docs/configuration.md +54 -19
  6. data/docs/failures.md +170 -22
  7. data/docs/harnesses.md +39 -15
  8. data/docs/measuring-tokens.md +12 -6
  9. data/docs/observers.md +3 -3
  10. data/docs/routing.md +14 -3
  11. data/docs/schemas.md +24 -2
  12. data/docs/testing.md +187 -0
  13. data/docs/verifying.md +86 -0
  14. data/lib/squishling/adapter/error_body.rb +54 -0
  15. data/lib/squishling/adapter.rb +328 -0
  16. data/lib/squishling/adapters/openai_compatible.rb +311 -0
  17. data/lib/squishling/adapters/openai_options.rb +114 -0
  18. data/lib/squishling/adapters/openai_transport.rb +123 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +229 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +58 -6
  24. data/lib/squishling/definition.rb +90 -16
  25. data/lib/squishling/errors.rb +94 -7
  26. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  27. data/lib/squishling/harness/option_types.rb +2 -1
  28. data/lib/squishling/harness/registry.rb +8 -36
  29. data/lib/squishling/invoker.rb +44 -14
  30. data/lib/squishling/judge.rb +16 -3
  31. data/lib/squishling/llm_client.rb +66 -132
  32. data/lib/squishling/model_path.rb +60 -13
  33. data/lib/squishling/named_registry.rb +64 -0
  34. data/lib/squishling/observer.rb +13 -4
  35. data/lib/squishling/output_check.rb +13 -9
  36. data/lib/squishling/params.rb +7 -24
  37. data/lib/squishling/railtie.rb +10 -0
  38. data/lib/squishling/result.rb +50 -16
  39. data/lib/squishling/rotation.rb +5 -0
  40. data/lib/squishling/router.rb +3 -2
  41. data/lib/squishling/rspec.rb +18 -0
  42. data/lib/squishling/run/messages.rb +40 -3
  43. data/lib/squishling/run/rejections.rb +43 -0
  44. data/lib/squishling/run/reports.rb +63 -0
  45. data/lib/squishling/run.rb +60 -53
  46. data/lib/squishling/schema.rb +50 -9
  47. data/lib/squishling/secret.rb +45 -0
  48. data/lib/squishling/squawk.rb +37 -4
  49. data/lib/squishling/tasks/squishling.rake +18 -0
  50. data/lib/squishling/testing/adapter.rb +194 -0
  51. data/lib/squishling/testing/checks.rb +39 -0
  52. data/lib/squishling/testing/direct.rb +41 -0
  53. data/lib/squishling/testing/errors.rb +18 -0
  54. data/lib/squishling/testing/fixtures.rb +206 -0
  55. data/lib/squishling/testing/helpers.rb +71 -0
  56. data/lib/squishling/testing/minitest.rb +32 -0
  57. data/lib/squishling/testing/observer.rb +31 -0
  58. data/lib/squishling/testing/reply.rb +60 -0
  59. data/lib/squishling/testing/request.rb +75 -0
  60. data/lib/squishling/testing/rspec.rb +30 -0
  61. data/lib/squishling/testing/script.rb +189 -0
  62. data/lib/squishling/testing/state.rb +74 -0
  63. data/lib/squishling/testing/stubs.rb +112 -0
  64. data/lib/squishling/testing.rb +126 -0
  65. data/lib/squishling/verification.rb +39 -0
  66. data/lib/squishling/verifier.rb +179 -0
  67. data/lib/squishling/version.rb +1 -1
  68. data/lib/squishling/wrapper.rb +19 -1
  69. data/lib/squishling.rb +60 -4
  70. metadata +43 -21
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "uri"
5
+
6
+ module Squishling
7
+ module Adapters
8
+ # Checks the options of Adapters::OpenAICompatible, raising ConfigurationError (without echoing a value that may
9
+ # be a secret) for a bad one.
10
+ module OpenAIOptions
11
+ # The URL itself is never in the message: it may carry credentials.
12
+ INVALID_BASE_URL = "adapter :openai_compatible base_url must be an http(s) URL without credentials (use " \
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"
20
+ INVALID_HEADER = "adapter :openai_compatible headers (and the api_key) can't contain line breaks or NUL"
21
+
22
+ module_function
23
+
24
+ def endpoint(base_url)
25
+ uri = URI.parse(base_url.to_s.strip.sub(%r{/+\z}, ""))
26
+ unless uri.is_a?(URI::HTTP) && uri.host && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
27
+ raise ConfigurationError, INVALID_BASE_URL
28
+ end
29
+
30
+ URI.parse("#{uri}/chat/completions")
31
+ rescue URI::InvalidURIError
32
+ raise ConfigurationError, INVALID_BASE_URL
33
+ end
34
+
35
+ def headers(headers)
36
+ unless headers.is_a?(Hash) && headers.all? { |name, value| name.is_a?(String) && value.is_a?(String) }
37
+ raise ConfigurationError, "adapter :openai_compatible headers must be a Hash of String names to String values"
38
+ end
39
+ raise ConfigurationError, INVALID_HEADER if headers.flatten.any? { |text| unsafe_header?(text) }
40
+
41
+ # A copy, so the caller can't change a header after the check above.
42
+ headers.to_h { |name, value| [name.dup.freeze, value.dup.freeze] }.freeze
43
+ end
44
+
45
+ # Net::HTTP raises, with the value in the message, for a header that carries a line break.
46
+ def unsafe_header?(text)
47
+ text.match?(/[\r\n\0]/)
48
+ end
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
+
84
+ def default_model(model)
85
+ return nil if model.nil?
86
+ return model.to_s if (model.is_a?(String) || model.is_a?(Symbol)) && !model.to_s.strip.empty?
87
+
88
+ raise ConfigurationError, "adapter :openai_compatible default_model must be a model name, got #{model.inspect}"
89
+ end
90
+
91
+ def number(name, value, type: Numeric)
92
+ return value if value.is_a?(type) && value.positive?
93
+
94
+ kind = type == Integer ? "Integer" : "number"
95
+ raise ConfigurationError, "adapter :openai_compatible #{name} must be a positive #{kind}, got #{value.inspect}"
96
+ end
97
+
98
+ def retries(value)
99
+ return value if value.is_a?(Integer) && !value.negative?
100
+
101
+ raise ConfigurationError,
102
+ "adapter :openai_compatible max_retries must be an Integer of 0 or more, got #{value.inspect}"
103
+ end
104
+
105
+ def choice(name, value, choices)
106
+ symbol = value.is_a?(String) ? value.to_sym : value
107
+ return symbol if choices.include?(symbol)
108
+
109
+ raise ConfigurationError,
110
+ "adapter :openai_compatible #{name} must be one of #{choices.join(', ')}, got #{value.inspect}"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -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
@@ -0,0 +1,229 @@
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. 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
25
+ require "ruby_llm"
26
+ check_version!
27
+ @lib = gem_lib_dir
28
+ rescue LoadError => e
29
+ raise unless e.path == "ruby_llm"
30
+
31
+ raise MissingGemError, "adapter :ruby_llm needs the ruby_llm gem; add `gem \"ruby_llm\"` to your Gemfile"
32
+ end
33
+
34
+ # One chat on the request's model: the system prompt, the strict output schema, and the params, then the
35
+ # conversation so far replayed, answering its last message. Models missing from RubyLLM's registry (e.g. newly
36
+ # released ones) are only usable when a provider is named, so RubyLLM is told to assume they exist.
37
+ def complete(request)
38
+ chat = build_chat(request)
39
+ *history, last = request.messages
40
+ history.each { |message| chat.add_message(role: message[:role], content: message[:content]) }
41
+ response = guarded(request) { chat.ask(last[:content]) }
42
+ Adapter::Response.new(content: response.content, model: response.model, tokens: tokens_of(response),
43
+ finish_reason: response.finish_reason)
44
+ end
45
+
46
+ def judgments?
47
+ true
48
+ end
49
+
50
+ # A System One judgment (RubyLLM.judge) on one step. The step's params are sent as provider options.
51
+ # RubyLLM raises ArgumentError for a judgment it can't build (e.g. a model on a provider that takes none, or
52
+ # a provider option its protocol reserves), and a bare RubyLLM::Error without an HTTP response for a provider
53
+ # that doesn't support judgments at all. An ArgumentError from anywhere else (an instrumentation subscriber
54
+ # runs inside the call) is the developer's own and propagates as-is.
55
+ def judge(input, questions:, step:, label:)
56
+ request = judgment_request(label, step)
57
+ guarded(request) do
58
+ ::RubyLLM.judge(input, questions:, provider_options: step.params, **model_options(step.model, step.provider))
59
+ rescue ::RubyLLM::ModelNotFoundError, ::RubyLLM::Error, ArgumentError => e
60
+ raise unless setup_failure?(e)
61
+
62
+ raise ConfigurationError, "#{label}: #{e.message}"
63
+ end
64
+ end
65
+
66
+ # RubyLLM's instrumentation context (the workflow and its captured context) is carried to the worker threads
67
+ # the way RubyLLM's own concurrent tool calls do it. Unlike those, the threads don't enter the Rails executor:
68
+ # the caller already holds it, and a second share can deadlock against a pending code reload.
69
+ def capture_context
70
+ instrumentation = ::RubyLLM::Support::Instrumentation if defined?(::RubyLLM::Support::Instrumentation)
71
+ return unless instrumentation.respond_to?(:current_workflow) && instrumentation.respond_to?(:capture_context)
72
+
73
+ [instrumentation.current_workflow, instrumentation.capture_context]
74
+ end
75
+
76
+ def with_context(context, &)
77
+ return yield unless context
78
+
79
+ instrumentation = ::RubyLLM::Support::Instrumentation
80
+ instrumentation.with_workflow(context.first) { instrumentation.with_context(context.last, &) }
81
+ end
82
+
83
+ private
84
+
85
+ def tokens_of(response)
86
+ response.tokens ? response.tokens.to_h : {}
87
+ end
88
+
89
+ # Whether a failure from RubyLLM.judge is a judgment RubyLLM can't make, rather than a failed request or the
90
+ # developer's own error.
91
+ def setup_failure?(error)
92
+ case error
93
+ when ::RubyLLM::ModelNotFoundError then true
94
+ when ArgumentError then raised_by_ruby_llm?(error)
95
+ else error.instance_of?(::RubyLLM::Error) && error.response.nil?
96
+ end
97
+ end
98
+
99
+ def judgment_request(label, step)
100
+ Adapter::Request.new(label:, model: step.model, provider: step.provider, instructions: nil, messages: [],
101
+ schema: nil, params: step.params)
102
+ end
103
+
104
+ def build_chat(request)
105
+ chat = ::RubyLLM.chat(**model_options(request.model, request.provider))
106
+ chat.with_instructions(request.instructions)
107
+ chat.with_schema(request.schema)
108
+ apply_params(chat, request)
109
+ chat
110
+ rescue ::RubyLLM::ModelNotFoundError, ::RubyLLM::ConfigurationError => e
111
+ raise configuration_error(request, e.message)
112
+ end
113
+
114
+ # RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
115
+ # and raises ArgumentError before any request is sent.
116
+ def apply_params(chat, request)
117
+ params = request.params
118
+ rest = params.except(:temperature, :thinking, :max_output_tokens)
119
+ chat.with_temperature(params[:temperature]) if params.key?(:temperature)
120
+ chat.with_max_output_tokens(params[:max_output_tokens]) if params.key?(:max_output_tokens)
121
+ apply_thinking(chat, params[:thinking]) if params.key?(:thinking)
122
+ chat.with_provider_options(rest) if rest.any?
123
+ rescue ArgumentError => e
124
+ raise configuration_error(request, "invalid params #{params.keys.inspect} (#{e.message})")
125
+ end
126
+
127
+ # true: the model's default thinking; false: off; a Hash: { effort:, budget:, display: }.
128
+ def apply_thinking(chat, thinking)
129
+ thinking.is_a?(Hash) ? chat.with_thinking(**thinking) : chat.with_thinking(thinking)
130
+ end
131
+
132
+ # Transient HTTP failures are already retried by RubyLLM; anything that still fails is surfaced as an
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.
136
+ # A provider's or transport's own message never goes into ours: a provider can echo request content or part of
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.
143
+ def guarded(request)
144
+ yield
145
+ rescue ::RubyLLM::ConfigurationError => e
146
+ raise configuration_error(request, "#{e.class}: #{e.message}")
147
+ rescue ::RubyLLM::Error, Faraday::Error => e
148
+ raise failure_for(request, e), cause: cause_of(e)
149
+ end
150
+
151
+ # Sorted by the HTTP status, not by RubyLLM's error class: RubyLLM's middleware picks a class from the provider's
152
+ # message text on a 400, so a bad parameter whose message happens to say "per day" arrives as a RateLimitError.
153
+ # An error with no response is sorted by its class. The one class honored over the status is RubyLLM's
154
+ # ContextLengthExceededError, since RubyLLM is where its provider's wording for an overlong prompt is known.
155
+ def failure_for(request, error)
156
+ via = describe_failure(error)
157
+ return llm_error(request, via) if error.is_a?(::RubyLLM::ContextLengthExceededError)
158
+
159
+ status = response_field(error.response, :status) || class_status(error)
160
+ http_failure(request, status:, body: response_field(error.response, :body), via:)
161
+ end
162
+
163
+ # The status RubyLLM's classes stand for, for an error raised without a response.
164
+ def class_status(error)
165
+ case error
166
+ when ::RubyLLM::UnauthorizedError then 401
167
+ when ::RubyLLM::PaymentRequiredError then 402
168
+ when ::RubyLLM::ForbiddenError then 403
169
+ when ::RubyLLM::BadRequestError then 400
170
+ end
171
+ end
172
+
173
+ # The error's class and HTTP status, when it has one, which is all of it that is ours to repeat.
174
+ def describe_failure(error)
175
+ status = status_of(error)
176
+ status ? "#{error.class}, HTTP #{status}" : error.class.to_s
177
+ end
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
+
187
+ # A field of an error's response, which is a Faraday::Response, or a Hash for a Faraday error.
188
+ def response_field(response, field)
189
+ response.is_a?(Hash) ? response[field] : (response.public_send(field) if response.respond_to?(field))
190
+ end
191
+
192
+ def model_options(model, provider)
193
+ options = { model:, provider: }.compact
194
+ options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
195
+ options
196
+ end
197
+
198
+ def known_model?(model, provider)
199
+ ::RubyLLM.models.find(model, provider:)
200
+ true
201
+ rescue ::RubyLLM::ModelNotFoundError
202
+ false
203
+ end
204
+
205
+ def raised_by_ruby_llm?(error)
206
+ error.backtrace&.first&.start_with?("#{@lib}/") || false
207
+ end
208
+
209
+ # The adapter uses RubyLLM 2.1 APIs (judgments, with_max_output_tokens, Tokens#to_h), and no gemspec pins it.
210
+ def check_version!
211
+ version = Gem.loaded_specs["ruby_llm"]&.version
212
+ return if version.nil? || version >= MINIMUM_VERSION
213
+
214
+ raise ConfigurationError, "adapter :ruby_llm needs ruby_llm #{MINIMUM_VERSION} or newer, got #{version}"
215
+ end
216
+
217
+ # Where RubyLLM's own code lives, to tell its ArgumentErrors from the developer's (see #judge).
218
+ # It is read from the file that was loaded, which is the path RubyLLM's backtrace frames carry.
219
+ def gem_lib_dir
220
+ entry = $LOADED_FEATURES.find { |feature| feature.end_with?("/lib/ruby_llm.rb") }
221
+ return File.dirname(entry) if entry
222
+
223
+ File.join(Gem.loaded_specs.fetch("ruby_llm").full_gem_path, "lib")
224
+ end
225
+ end
226
+
227
+ Adapter::Registry.register_built_in(:ruby_llm, RubyLLM)
228
+ end
229
+ 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
@@ -9,7 +9,7 @@ module Squishling
9
9
  # squishling model: "claude-sonnet-5-5", purpose: "...", output_schema: MySchema
10
10
  # model: is a single attempt; escalation: is a list of models tried in order (see ModelPath):
11
11
  # squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
12
- # Pass provider: alongside model: for models missing from RubyLLM's registry, e.g.
12
+ # Pass provider: alongside model: for models missing from RubyLLM's registry (the RubyLLM adapter), e.g.
13
13
  # squishling model: "gpt-7-preview", provider: :openai
14
14
  # provider: needs a model: or escalation: (declared on the class), and a subclass's model never inherits its
15
15
  # parent's provider.
@@ -19,11 +19,19 @@ module Squishling
19
19
  # squishling append_to_purpose: ["The Ruby that handles well-formed input:", self]
20
20
  # harness: chooses how the escalation is used (see Harness):
21
21
  # squishling harness: :judged_squishsum
22
- # squawk: is called after every LLM attempt with the raw output (see Configuration#squawk); false silences
22
+ # max_total_output_tokens:/max_total_input_tokens: cap the tokens one call may use across all its requests
23
+ # (see Budget); false lifts a cap inherited from the parent class or the configuration:
24
+ # squishling max_total_output_tokens: 25_000
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
23
27
  # an inherited one:
24
28
  # squishling squawk: ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
29
+ # squawk_on: limits which attempts reach the hook: :all (the default), :rejections, :retries, :final, or a
30
+ # list of them (any one holding runs the hook):
31
+ # squishling squawk: hook, squawk_on: %i[rejections final]
25
32
  def squishling(model: nil, escalation: nil, provider: nil, params: nil, harness: nil, purpose: nil,
26
- append_to_purpose: nil, output_schema: nil, squawk: nil)
33
+ append_to_purpose: nil, output_schema: nil, squawk: nil, squawk_on: nil, max_total_output_tokens: nil,
34
+ max_total_input_tokens: nil)
27
35
  path = ModelPath.declare(model, escalation, to_s)
28
36
  # A provider belongs to the model declared at the same level. Without a model here it would apply to
29
37
  # nothing (the model comes from the config or a parent class), so it is an error unless this class already
@@ -41,14 +49,24 @@ module Squishling
41
49
  self.purpose(purpose) if purpose
42
50
  self.append_to_purpose(append_to_purpose) unless append_to_purpose.nil?
43
51
  self.output_schema(output_schema) if output_schema
44
- @squishling_squawk = Squawk.validate(squawk, to_s) unless squawk.nil?
52
+ declare_squawk(squawk, squawk_on)
53
+ declare_budget(max_total_output_tokens, max_total_input_tokens)
45
54
  self
46
55
  end
47
56
 
57
+ # The token limits declared on this class and its ancestors, limit by limit (see Budget).
58
+ def squishling_budget
59
+ squishling_inherited(:squishling_budget, {}).merge(@squishling_budget || {})
60
+ end
61
+
48
62
  def squishling_squawk
49
63
  squishling_lookup(:@squishling_squawk)
50
64
  end
51
65
 
66
+ def squishling_squawk_on
67
+ squishling_lookup(:@squishling_squawk_on)
68
+ end
69
+
52
70
  # The class's (or nearest ancestor's) model or escalation, as normalized steps.
53
71
  def squishling_model_path
54
72
  squishling_lookup(:@squishling_model_path)
@@ -147,11 +165,14 @@ module Squishling
147
165
  # squish :triage, purpose: "...", escalation: %w[claude-haiku-4-5 claude-sonnet-5-5], when: ->(**) { true },
148
166
  # fallback: ->(error, **) { { priority: "medium" } }, append_to_purpose: [...],
149
167
  # validate: ->(result, **) { "team is required" if result.team.empty? },
150
- # harness: :squishsum, squawk: ->(output:, error:, **) { Tracer.record(output, error) } do
168
+ # harness: :squishsum, max_total_output_tokens: 25_000,
169
+ # squawk: ->(output:, error:, **) { Tracer.record(output, error) },
170
+ # squawk_on: :retries do
151
171
  # string :priority
152
172
  # end
153
173
  def squish(*names, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil, escalation: nil,
154
- provider: nil, params: nil, harness: nil, when: nil, fallback: nil, validate: nil, squawk: nil, &schema_block)
174
+ provider: nil, params: nil, harness: nil, when: nil, fallback: nil, validate: nil, squawk: nil,
175
+ squawk_on: nil, max_total_output_tokens: nil, max_total_input_tokens: nil, &schema_block)
155
176
  schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
156
177
  model = ModelPath.declare(model, escalation, "#{self} squish")
157
178
  raise ConfigurationError, "#{self} squish: provider: needs a model: or escalation:" if provider && !model
@@ -162,8 +183,11 @@ module Squishling
162
183
  append_to_purpose = Appendices.normalize(append_to_purpose, "#{self} squish append_to_purpose")
163
184
  end
164
185
  squawk = Squawk.validate(squawk, "#{self} squish")
186
+ squawk_on = Squawk.validate_on(squawk_on, "#{self} squish")
187
+ budget = Budget.build("#{self} squish", output: max_total_output_tokens, input: max_total_input_tokens)
165
188
  options = { purpose:, append_to_purpose:, output_schema: schema, model:, provider:, params:, harness:,
166
- predicate: binding.local_variable_get(:when), fallback:, validator: validate, squawk: }.compact
189
+ predicate: binding.local_variable_get(:when), fallback:, validator: validate, squawk:,
190
+ squawk_on:, budget: }.compact
167
191
 
168
192
  names.map(&:to_sym).each do |name|
169
193
  (@squishling_methods ||= {})[name] = options
@@ -192,8 +216,20 @@ module Squishling
192
216
 
193
217
  private
194
218
 
219
+ # nil leaves a level unset (so the parent's applies); false for squawk: is stored, to silence it.
220
+ def declare_squawk(squawk, squawk_on)
221
+ @squishling_squawk = Squawk.validate(squawk, to_s) unless squawk.nil?
222
+ @squishling_squawk_on = Squawk.validate_on(squawk_on, "#{self} squishling") unless squawk_on.nil?
223
+ end
224
+
225
+ # Adds the class's own token limits to those it declared before.
226
+ def declare_budget(output, input)
227
+ @squishling_budget = (@squishling_budget || {}).merge(Budget.build(to_s, output:, input:)).freeze
228
+ end
229
+
195
230
  # Each class gets its own prepended wrapper so overrides in subclasses are routed too.
196
231
  def squishling_install_wrapper
232
+ ClassRegistry.register(self)
197
233
  @squishling_wrapper = Wrapper.new
198
234
  prepend(@squishling_wrapper)
199
235