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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -0
- data/README.md +26 -10
- data/docs/adapters.md +257 -0
- data/docs/configuration.md +54 -19
- data/docs/failures.md +170 -22
- data/docs/harnesses.md +39 -15
- data/docs/measuring-tokens.md +12 -6
- data/docs/observers.md +3 -3
- data/docs/routing.md +14 -3
- data/docs/schemas.md +24 -2
- data/docs/testing.md +187 -0
- data/docs/verifying.md +86 -0
- data/lib/squishling/adapter/error_body.rb +54 -0
- data/lib/squishling/adapter.rb +328 -0
- data/lib/squishling/adapters/openai_compatible.rb +311 -0
- data/lib/squishling/adapters/openai_options.rb +114 -0
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +229 -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 +58 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +94 -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 +44 -14
- 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 +13 -4
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +7 -24
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +50 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/router.rb +3 -2
- 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/reports.rb +63 -0
- data/lib/squishling/run.rb +60 -53
- data/lib/squishling/schema.rb +50 -9
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +37 -4
- 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 +74 -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/wrapper.rb +19 -1
- data/lib/squishling.rb +60 -4
- 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
|
-
#
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|