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,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The assertions, independent of any test framework: each returns a failure message, or nil when it holds. The
|
|
6
|
+
# Minitest and RSpec mixins turn a message into a failure in their own way.
|
|
7
|
+
module Checks
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# The requests of a fake adapter (or an Array of them) sent exactly these arguments, as JSON would carry them.
|
|
11
|
+
def sent_arguments(source, expected, request: 0)
|
|
12
|
+
sent = requests_of(source)[request]
|
|
13
|
+
return "expected request ##{request} to have been sent, but only #{requests_of(source).size} were" unless sent
|
|
14
|
+
|
|
15
|
+
wanted = Schema.jsonify(expected)
|
|
16
|
+
return nil if sent.arguments == wanted
|
|
17
|
+
|
|
18
|
+
"expected the model to be sent arguments #{wanted.inspect}, but it was sent #{sent.arguments.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call_count(source, expected)
|
|
22
|
+
actual = requests_of(source).size
|
|
23
|
+
actual == expected ? nil : "expected #{expected} LLM call(s), but there were #{actual}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs the block with provider calls forbidden: a failure message when it tried to make one.
|
|
27
|
+
def no_llm_call(&)
|
|
28
|
+
attempts = Testing.no_llm_call(&)
|
|
29
|
+
return nil if attempts.empty?
|
|
30
|
+
|
|
31
|
+
"expected no LLM call, but #{attempts.uniq.join(', ')} reached the provider (#{attempts.size} request(s))"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def requests_of(source)
|
|
35
|
+
source.respond_to?(:requests) ? source.requests : Array(source)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# Runs the parts of a squished method that don't need a model, for unit tests: the output checks, the fallback,
|
|
6
|
+
# and the prompt. Each takes the method's arguments as args: and keywords (as the method is called), and a
|
|
7
|
+
# receiver: for a class whose constructor takes arguments.
|
|
8
|
+
module Direct
|
|
9
|
+
class << self
|
|
10
|
+
# The messages squish_validate and the output schema reject this reply with ([] when it would be accepted).
|
|
11
|
+
def validate(klass, reply, method: :call, receiver: nil, args: [], **kwargs)
|
|
12
|
+
definition = klass.squishling_definition(method)
|
|
13
|
+
schema = definition.schema or raise SetupError, "#{definition.label} has no output_schema"
|
|
14
|
+
inputs = definition.bind_arguments(args, kwargs)
|
|
15
|
+
prompt = Invoker::Prompt.new(instructions: nil, schema:, input: nil, role: nil)
|
|
16
|
+
OutputCheck.new(definition, receiver || klass.new, inputs).call(reply, prompt).last.map(&:message)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# What squish_fallback returns for this error, typed like any result of the method.
|
|
20
|
+
def fallback_for(klass, error, method: :call, receiver: nil, args: [], **kwargs)
|
|
21
|
+
definition = klass.squishling_definition(method)
|
|
22
|
+
handler = definition.fallback or raise SetupError, "#{definition.label} has no squish_fallback"
|
|
23
|
+
inputs = definition.bind_arguments(args, kwargs)
|
|
24
|
+
definition.coerce((receiver || klass.new).instance_exec(error, **inputs, &handler))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The first request a call would make, without making it: { system_prompt:, user_message:, output_schema:,
|
|
28
|
+
# step: } (the step is the ModelPath::Step with the model, provider and params; rotation isn't advanced).
|
|
29
|
+
def preview(klass, method: :call, receiver: nil, args: [], **kwargs)
|
|
30
|
+
definition = klass.squishling_definition(method)
|
|
31
|
+
receiver ||= klass.new
|
|
32
|
+
inputs = definition.bind_arguments(args, kwargs)
|
|
33
|
+
prompt = Invoker.new(definition, receiver, inputs).prompt
|
|
34
|
+
{ system_prompt: prompt.instructions, user_message: prompt.input,
|
|
35
|
+
output_schema: prompt.schema.llm_schema["schema"],
|
|
36
|
+
step: definition.escalation_path(receiver, inputs, advance: false).first }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# What the test helpers raise. None of these is a Squishling::Error, so squish_fallback, which absorbs
|
|
6
|
+
# InvalidOutputError and LLMError, can never hide a test setup mistake.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# A request reached the test adapter and no script covers it.
|
|
10
|
+
class UnscriptedRequestError < Error; end
|
|
11
|
+
|
|
12
|
+
# A request reached the provider seam while provider calls were forbidden (see Testing.forbid_provider_calls!).
|
|
13
|
+
class ProviderCallError < Error; end
|
|
14
|
+
|
|
15
|
+
# A stub, a fixture, or a direct check was asked for something the method's schema doesn't allow.
|
|
16
|
+
class SetupError < Error; end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# Replies derived from an output schema, so a test that doesn't care about the content stays valid when the schema
|
|
6
|
+
# changes, and a test of "out of range gets re-asked" doesn't hand-write a bad payload. Replies are Hashes with
|
|
7
|
+
# string keys, as JSON would carry them, and are always checked against the schema before they are returned.
|
|
8
|
+
module Fixtures
|
|
9
|
+
FORMATS = {
|
|
10
|
+
"date-time" => "2024-01-01T00:00:00Z", "date" => "2024-01-01", "time" => "00:00:00Z",
|
|
11
|
+
"email" => "user@example.com", "uri" => "https://example.com", "uuid" => "00000000-0000-4000-8000-000000000000"
|
|
12
|
+
}.freeze
|
|
13
|
+
MAX_DEPTH = 8
|
|
14
|
+
WRONG_VALUES = ["not valid", -1_000_000_007, [], {}, true, nil].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# A reply that satisfies the schema of a squished class's method, a Squishling::Schema, or a schema Hash.
|
|
18
|
+
# overrides replace top-level attributes.
|
|
19
|
+
def valid_reply_for(source, method: :call, **overrides)
|
|
20
|
+
schema = schema_of(source, method)
|
|
21
|
+
reply = Generator.new(schema.json_schema).value
|
|
22
|
+
reply = reply.merge(overrides.transform_keys(&:to_s)) if reply.is_a?(Hash)
|
|
23
|
+
verify!(schema, reply, "valid")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A valid reply with one top-level attribute (violating:) broken: out of its enum or range, the wrong type,
|
|
27
|
+
# null, or missing. how: (:enum, :range, :type, :null, :missing) picks the break; by default the first that
|
|
28
|
+
# the schema rejects. Overrides are applied to the valid reply first.
|
|
29
|
+
def invalid_reply_for(source, violating:, how: nil, method: :call, **overrides)
|
|
30
|
+
schema = schema_of(source, method)
|
|
31
|
+
base = valid_reply_for(schema, **overrides)
|
|
32
|
+
key = violating.to_s
|
|
33
|
+
raise SetupError, "the schema has no property #{key.inspect} to violate" unless schema.json_schema.fetch(
|
|
34
|
+
"properties", {}
|
|
35
|
+
).key?(key)
|
|
36
|
+
|
|
37
|
+
candidates = breaks(schema.json_schema, key, how)
|
|
38
|
+
broken = candidates.lazy.map { |value| mutate(base, key, value) }.find { |reply| schema.validate(reply).any? }
|
|
39
|
+
broken or raise SetupError,
|
|
40
|
+
"couldn't find a value of #{key.inspect} that the schema rejects#{" (how: #{how.inspect})" if how}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def schema_of(source, method)
|
|
46
|
+
case source
|
|
47
|
+
when Schema then source
|
|
48
|
+
when Class then source.squishling_definition(method).schema or raise SetupError,
|
|
49
|
+
"#{source} has no output_schema"
|
|
50
|
+
else Schema.for(source)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def verify!(schema, reply, kind)
|
|
55
|
+
errors = schema.validate(reply)
|
|
56
|
+
return reply if errors.empty?
|
|
57
|
+
|
|
58
|
+
raise SetupError,
|
|
59
|
+
"couldn't build a #{kind} reply (#{errors.join('; ')}); pass the attributes it needs as overrides"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def mutate(base, key, value)
|
|
63
|
+
value.equal?(:missing) ? base.except(key) : base.merge(key => value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def breaks(json_schema, key, how)
|
|
67
|
+
node = Generator.new(json_schema).node_for(json_schema.fetch("properties").fetch(key))
|
|
68
|
+
all = { enum: enum_breaks(node), range: range_breaks(node), type: WRONG_VALUES.compact, null: [nil],
|
|
69
|
+
missing: [:missing] }
|
|
70
|
+
return all.fetch(how) { raise ArgumentError, "how: is one of #{all.keys.map(&:inspect).join(', ')}" } if how
|
|
71
|
+
|
|
72
|
+
all.values.flatten(1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def enum_breaks(node)
|
|
76
|
+
values = node["enum"] ? ["not in the enum"] : []
|
|
77
|
+
node.key?("const") ? values + ["not the const"] : values
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def range_breaks(node)
|
|
81
|
+
bounds = node.slice("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minLength", "maxLength",
|
|
82
|
+
"minItems", "maxItems")
|
|
83
|
+
bounds.flat_map { |name, limit| beyond(name, limit) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def beyond(name, limit)
|
|
87
|
+
case name
|
|
88
|
+
when "minimum", "exclusiveMinimum" then [limit - 1]
|
|
89
|
+
when "maximum", "exclusiveMaximum" then [limit + 1]
|
|
90
|
+
when "minLength" then ["x" * [limit - 1, 0].max]
|
|
91
|
+
when "maxLength" then ["x" * (limit + 1)]
|
|
92
|
+
when "minItems" then [[nil] * [limit - 1, 0].max]
|
|
93
|
+
when "maxItems" then [[nil] * (limit + 1)]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Builds one value that satisfies a JSON schema (see Fixtures).
|
|
99
|
+
class Generator
|
|
100
|
+
def initialize(root)
|
|
101
|
+
@root = root
|
|
102
|
+
@expanding = []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The value for a node. A $ref being expanded is remembered, so a recursive schema (a node whose child is
|
|
106
|
+
# the same node or null) ends at the null instead of nesting forever.
|
|
107
|
+
def value(node = @root, depth = 0)
|
|
108
|
+
ref = node["$ref"]
|
|
109
|
+
@expanding.push(ref) if ref
|
|
110
|
+
generate(node_for(node), depth)
|
|
111
|
+
ensure
|
|
112
|
+
@expanding.pop if ref
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The schema node with a root-relative $ref followed.
|
|
116
|
+
def node_for(node)
|
|
117
|
+
ref = node["$ref"]
|
|
118
|
+
return node unless ref
|
|
119
|
+
|
|
120
|
+
path = ref.delete_prefix("#").split("/").reject(&:empty?)
|
|
121
|
+
node.except("$ref").merge(path.reduce(@root) do |memo, part|
|
|
122
|
+
memo.fetch(part.gsub("~1", "/").gsub("~0", "~"))
|
|
123
|
+
end)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def generate(node, depth)
|
|
129
|
+
return node["const"] if node.key?("const")
|
|
130
|
+
return node["enum"].first if node["enum"]
|
|
131
|
+
return from_branch(node, depth) if node["anyOf"] || node["oneOf"]
|
|
132
|
+
return from_all_of(node, depth) if node["allOf"]
|
|
133
|
+
|
|
134
|
+
typed(node, depth)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def from_branch(node, depth)
|
|
138
|
+
branches = node["anyOf"] || node["oneOf"]
|
|
139
|
+
open = branches.reject { |branch| branch["$ref"] && @expanding.include?(branch["$ref"]) }
|
|
140
|
+
chosen = open.find { |branch| branch["type"] != "null" } || open.first || branches.first
|
|
141
|
+
value(chosen, depth + 1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def from_all_of(node, depth)
|
|
145
|
+
parts = node["allOf"].map { |part| value(part, depth + 1) }.grep(Hash)
|
|
146
|
+
parts.reduce({}) { |memo, part| memo.merge(part) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def typed(node, depth)
|
|
150
|
+
case primary_type(node)
|
|
151
|
+
when "object" then object(node, depth)
|
|
152
|
+
when "array" then array(node, depth)
|
|
153
|
+
when "string" then string(node)
|
|
154
|
+
when "integer", "number" then number(node)
|
|
155
|
+
when "boolean" then true
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def primary_type(node)
|
|
160
|
+
types = Array(node["type"])
|
|
161
|
+
types.find { |type| type != "null" } || (types.first if types.any?) || inferred_type(node)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def inferred_type(node)
|
|
165
|
+
return "object" if node["properties"]
|
|
166
|
+
|
|
167
|
+
"array" if node["items"]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def object(node, depth)
|
|
171
|
+
return {} if depth > MAX_DEPTH
|
|
172
|
+
|
|
173
|
+
(node["properties"] || {}).transform_values { |property| value(property, depth + 1) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def array(node, depth)
|
|
177
|
+
return [] if depth > MAX_DEPTH || !node["items"]
|
|
178
|
+
|
|
179
|
+
Array.new(node["minItems"].to_i) { value(node["items"], depth + 1) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def string(node)
|
|
183
|
+
text = FORMATS.fetch(node["format"], "example")
|
|
184
|
+
text = text.ljust(node["minLength"], "x") if node["minLength"]
|
|
185
|
+
node["maxLength"] ? text[0, node["maxLength"]] : text
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def number(node)
|
|
189
|
+
number = clamp_up(0, node["minimum"], node["exclusiveMinimum"])
|
|
190
|
+
number = clamp_down(number, node["maximum"], node["exclusiveMaximum"])
|
|
191
|
+
primary_type(node) == "integer" ? number.ceil : number
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def clamp_up(number, minimum, exclusive)
|
|
195
|
+
number = minimum if minimum && number < minimum
|
|
196
|
+
exclusive && number <= exclusive ? exclusive + 1 : number
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def clamp_down(number, maximum, exclusive)
|
|
200
|
+
number = maximum if maximum && number > maximum
|
|
201
|
+
exclusive && number >= exclusive ? exclusive - 1 : number
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The test-facing helpers, shared by the Minitest and RSpec mixins. A mixin supplies #squishling_failure(message),
|
|
6
|
+
# which fails the current test.
|
|
7
|
+
module Helpers
|
|
8
|
+
# Installs a scripted adapter and returns it. The block scripts it (see Script); the adapter's #requests,
|
|
9
|
+
# #conversations and #arguments show what was sent.
|
|
10
|
+
def fake_squishling(&)
|
|
11
|
+
Testing.install(&)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Any LLM request raises until a test installs a fake, naming the squished method that made it.
|
|
15
|
+
def forbid_provider_calls!
|
|
16
|
+
Testing.forbid_provider_calls!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def assert_sent_arguments(source, expected, request: 0)
|
|
20
|
+
failure = Checks.sent_arguments(source, expected, request:)
|
|
21
|
+
squishling_failure(failure) if failure
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def assert_llm_calls(source, expected)
|
|
25
|
+
failure = Checks.call_count(source, expected)
|
|
26
|
+
squishling_failure(failure) if failure
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assert_no_llm_call(&)
|
|
30
|
+
failure = Checks.no_llm_call(&)
|
|
31
|
+
squishling_failure(failure) if failure
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stub_squishling_result(klass, attrs = nil, **, &)
|
|
35
|
+
Testing.stub_result(klass, attrs, **, &)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stub_squishling_error(klass, kind, **, &)
|
|
39
|
+
Testing.stub_error(klass, kind, **, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def squishling_validate(klass, reply, **inputs)
|
|
43
|
+
Testing.validate(klass, reply, **inputs)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def squishling_fallback_for(klass, error, **inputs)
|
|
47
|
+
Testing.fallback_for(klass, error, **inputs)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def squishling_preview(klass, **inputs)
|
|
51
|
+
Testing.preview(klass, **inputs)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def valid_reply_for(source, **overrides)
|
|
55
|
+
Testing.valid_reply_for(source, **overrides)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def invalid_reply_for(source, **)
|
|
59
|
+
Testing.invalid_reply_for(source, **)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def capture_squawk
|
|
63
|
+
Testing.capture_squawk
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def with_squawk(capture = capture_squawk, &)
|
|
67
|
+
Testing.with_squawk(capture, &)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The helpers for Minitest: include it in a test case (or ActiveSupport::TestCase). What it installs (the
|
|
6
|
+
# adapter, observer, and stubs) is put back after each test.
|
|
7
|
+
#
|
|
8
|
+
# class RegexInferrerTest < Minitest::Test
|
|
9
|
+
# include Squishling::Testing::Minitest
|
|
10
|
+
#
|
|
11
|
+
# def test_retries_a_bad_regex
|
|
12
|
+
# fake = fake_squishling { |llm| llm.step(:any).reply({ regex: "[bad(" }, { regex: "Run.*" }) }
|
|
13
|
+
# assert_equal "Run.*", RegexInferrer.call(sample_turns: turns).regex
|
|
14
|
+
# assert_sent_arguments fake, { sample_turns: turns }
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
module Minitest
|
|
18
|
+
include Helpers
|
|
19
|
+
|
|
20
|
+
def after_teardown
|
|
21
|
+
Testing.reset!
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def squishling_failure(message)
|
|
28
|
+
flunk message
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# Tells the test adapter which request it is answering. An Adapter::Request carries no role, sample, or attempt,
|
|
6
|
+
# but the Observer::Call that wraps it does, on the thread that makes the request: this observer keeps it where
|
|
7
|
+
# Adapter#complete can read it, and passes the request on to any observer the app configured.
|
|
8
|
+
class Observer
|
|
9
|
+
KEY = :squishling_testing_call
|
|
10
|
+
|
|
11
|
+
# The call being made on this thread, or nil outside a request.
|
|
12
|
+
def self.current
|
|
13
|
+
Thread.current[KEY]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :previous
|
|
17
|
+
|
|
18
|
+
def initialize(previous = nil)
|
|
19
|
+
@previous = previous
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def around(call, &)
|
|
23
|
+
outer = Thread.current[KEY]
|
|
24
|
+
Thread.current[KEY] = call
|
|
25
|
+
@previous ? @previous.around(call, &) : yield
|
|
26
|
+
ensure
|
|
27
|
+
Thread.current[KEY] = outer
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# A scripted answer with more than content: the model id the provider reports and its token counts, so the
|
|
6
|
+
# squawk and observer usage paths are covered. usage is a Hash such as { input_tokens: 12, output_tokens: 30 }.
|
|
7
|
+
class Reply
|
|
8
|
+
attr_reader :content, :model, :usage
|
|
9
|
+
|
|
10
|
+
def initialize(content = nil, model: nil, usage: nil)
|
|
11
|
+
@content = content
|
|
12
|
+
@model = model
|
|
13
|
+
@usage = usage
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A scripted provider failure, named by what went wrong (see Failures).
|
|
18
|
+
Failure = Data.define(:intent)
|
|
19
|
+
|
|
20
|
+
# The answer to the "winner" question of a System One judgment.
|
|
21
|
+
Answer = Struct.new(:choice, :probabilities)
|
|
22
|
+
|
|
23
|
+
# What Adapter#judge returns: answers by question name, like RubyLLM's Judgment.
|
|
24
|
+
Judgment = Struct.new(:answers) do
|
|
25
|
+
def [](name)
|
|
26
|
+
answers[name]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The provider failures a script can name by intent rather than by the error class of any one SDK. Each becomes
|
|
31
|
+
# the Squishling error an adapter raises for it: LLMError for what is worth another attempt, ConfigurationError
|
|
32
|
+
# for what is not. Messages carry the failure and its status, like the real adapters' do.
|
|
33
|
+
module Failures
|
|
34
|
+
# intent => [error class, what happened, HTTP status or nil]
|
|
35
|
+
INTENTS = {
|
|
36
|
+
rate_limit: [LLMError, "rate limited", 429],
|
|
37
|
+
server_error: [LLMError, "server error", 500],
|
|
38
|
+
overloaded: [LLMError, "provider overloaded", 529],
|
|
39
|
+
timeout: [LLMError, "request timed out", nil],
|
|
40
|
+
context_length: [LLMError, "context length exceeded", 400],
|
|
41
|
+
bad_request: [ConfigurationError, "bad request", 400],
|
|
42
|
+
unauthorized: [ConfigurationError, "unauthorized", 401],
|
|
43
|
+
forbidden: [ConfigurationError, "forbidden", 403]
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def check!(intent)
|
|
49
|
+
return intent if INTENTS.key?(intent)
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "unknown failure #{intent.inspect} (one of #{INTENTS.keys.map(&:inspect).join(', ')})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build(intent, label)
|
|
55
|
+
klass, text, status = INTENTS.fetch(intent)
|
|
56
|
+
klass.new("#{label}: #{text}#{" (HTTP #{status})" if status}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# Run's follow-up messages (a private constant of Run): the helpers read what was sent through their parsers.
|
|
6
|
+
MESSAGES = Run.const_get(:Messages)
|
|
7
|
+
private_constant :MESSAGES
|
|
8
|
+
|
|
9
|
+
# One provider request as the test adapter saw it: what left the process, and what came back.
|
|
10
|
+
#
|
|
11
|
+
# arguments and context are the method's arguments and the squish_context / squish! context values the model was
|
|
12
|
+
# sent. For a judge they are those of the call being judged, and candidates holds the two samples. messages is
|
|
13
|
+
# the conversation as sent (empty for a System One judgment, whose payload is the input). conversation is the
|
|
14
|
+
# index of the chat in Adapter#conversations (nil for a judgment): a retry is the same conversation, an
|
|
15
|
+
# escalation a new one. reply is the Adapter::Response, or the error raised instead.
|
|
16
|
+
Request = Data.define(:label, :type, :role, :sample, :attempt, :attempts, :model, :provider, :params,
|
|
17
|
+
:system_prompt, :output_schema, :messages, :conversation, :payload, :reply) do
|
|
18
|
+
def arguments
|
|
19
|
+
input["arguments"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def context
|
|
23
|
+
input["context"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def candidates
|
|
27
|
+
payload["candidates"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The validation messages this request carried back to the model: a retry in the same conversation, or an
|
|
31
|
+
# escalation to the next step. Empty for a first attempt.
|
|
32
|
+
def errors
|
|
33
|
+
(retry_errors || escalation&.errors) || []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The rejected output forwarded from the previous step (text, capped), or nil when none was.
|
|
37
|
+
def forwarded_rejected
|
|
38
|
+
escalation&.rejected
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def failed?
|
|
42
|
+
reply.is_a?(Exception)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def judge?
|
|
46
|
+
role == :judge
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The JSON payload of the first user message, parsed (an escalation message wraps it in its own text).
|
|
50
|
+
def self.payload_of(messages)
|
|
51
|
+
text = messages.first.fetch(:content)
|
|
52
|
+
JSON.parse(MESSAGES.parse_escalation(text)&.input || text)
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# The method's arguments and context: the payload itself, or inside a judge's "input".
|
|
60
|
+
def input
|
|
61
|
+
judge? ? payload.fetch("input", {}) : payload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def retry_errors
|
|
65
|
+
text = messages.last&.fetch(:content, nil)
|
|
66
|
+
messages.size > 1 && text ? MESSAGES.parse_retry(text) : nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def escalation
|
|
70
|
+
text = messages.first&.fetch(:content, nil)
|
|
71
|
+
text && MESSAGES.parse_escalation(text)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The helpers for RSpec (no rspec-mocks needed): `config.include Squishling::Testing::RSpec`, or include it in a
|
|
6
|
+
# group. What it installs (the adapter, observer, and stubs) is put back after each example.
|
|
7
|
+
#
|
|
8
|
+
# RSpec.describe RegexInferrer do
|
|
9
|
+
# it "retries a bad regex" do
|
|
10
|
+
# fake = fake_squishling { |llm| llm.step(:any).reply({ regex: "[bad(" }, { regex: "Run.*" }) }
|
|
11
|
+
# expect(described_class.call(sample_turns: turns).regex).to eq("Run.*")
|
|
12
|
+
# assert_sent_arguments fake, { sample_turns: turns }
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module RSpec
|
|
16
|
+
include Helpers
|
|
17
|
+
|
|
18
|
+
def self.included(group)
|
|
19
|
+
super
|
|
20
|
+
group.after { Testing.reset! }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def squishling_failure(message)
|
|
26
|
+
raise ::RSpec::Expectations::ExpectationNotMetError, message
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|