squishling 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +25 -9
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +53 -18
- data/docs/failures.md +123 -18
- data/docs/harnesses.md +24 -14
- data/docs/measuring-tokens.md +8 -6
- data/docs/routing.md +5 -0
- data/docs/schemas.md +11 -2
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +57 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +92 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +21 -9
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +11 -2
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +6 -23
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +66 -42
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +57 -4
- metadata +40 -21
|
@@ -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:) with the raw output (see
|
|
26
|
+
# 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
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Every class that includes Squishling (and each subclass of one), so they can be verified ahead of runtime.
|
|
5
|
+
# Held weakly, so classes replaced by code reloading (e.g. Rails in development) can be collected; a class that is
|
|
6
|
+
# replaced but not yet collected is left out of the list. Shared across threads, so every access is under the lock.
|
|
7
|
+
module ClassRegistry
|
|
8
|
+
LOCK = Mutex.new
|
|
9
|
+
GONE = Object.new.freeze
|
|
10
|
+
UNKNOWN = Object.new.freeze
|
|
11
|
+
private_constant :LOCK, :GONE, :UNKNOWN
|
|
12
|
+
|
|
13
|
+
@classes = ObjectSpace::WeakMap.new
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def register(klass)
|
|
17
|
+
LOCK.synchronize { @classes[klass] = true }
|
|
18
|
+
klass
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The live classes, in no particular order, as a snapshot.
|
|
22
|
+
def classes
|
|
23
|
+
LOCK.synchronize { @classes.keys }.reject { |klass| replaced?(klass) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Forgets every class, e.g. between specs.
|
|
27
|
+
def reset!
|
|
28
|
+
LOCK.synchronize { @classes = ObjectSpace::WeakMap.new }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# A named class whose constant is gone or now holds another object (a reload has replaced it). Anonymous
|
|
34
|
+
# classes have no constant to go stale, and a name that can't be walked (under an anonymous module, a temporary
|
|
35
|
+
# name) is taken as live.
|
|
36
|
+
def replaced?(klass)
|
|
37
|
+
name = Module.instance_method(:name).bind_call(klass) # the class may define its own .name
|
|
38
|
+
return false unless name
|
|
39
|
+
|
|
40
|
+
found = lookup(name)
|
|
41
|
+
!found.equal?(UNKNOWN) && !found.equal?(klass)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def lookup(name)
|
|
45
|
+
name.split("::").reduce(Object) do |scope, constant|
|
|
46
|
+
found = child(scope, constant)
|
|
47
|
+
return found if found.equal?(GONE) || found.equal?(UNKNOWN)
|
|
48
|
+
|
|
49
|
+
found
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The constant in scope, without autoloading it: GONE when it is missing or a pending autoload (Zeitwerk resets
|
|
54
|
+
# a reloaded constant to one, and loading it would run app code), UNKNOWN when the name can't be walked.
|
|
55
|
+
def child(scope, constant)
|
|
56
|
+
return UNKNOWN unless scope.is_a?(Module) && constant.match?(/\A[A-Z]\w*\z/)
|
|
57
|
+
return GONE if scope.autoload?(constant, false) || !scope.const_defined?(constant, false)
|
|
58
|
+
|
|
59
|
+
scope.const_get(constant, false)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Squishling
|
|
4
4
|
class Configuration
|
|
5
5
|
# Model used by every squishling class that doesn't declare its own (a single attempt).
|
|
6
|
-
# When neither this nor default_escalation is set,
|
|
6
|
+
# When neither this nor default_escalation is set, the adapter's own default model is used.
|
|
7
7
|
attr_reader :default_model
|
|
8
8
|
|
|
9
9
|
# Models tried in order by every squishling class that doesn't declare its own, e.g.
|
|
@@ -14,13 +14,14 @@ module Squishling
|
|
|
14
14
|
# The validated default_model or default_escalation steps (nil when neither is set).
|
|
15
15
|
attr_reader :default_model_path
|
|
16
16
|
|
|
17
|
-
# Provider for default_model/default_escalation steps that don't name their own (e.g. :openai).
|
|
18
|
-
#
|
|
17
|
+
# Provider for default_model/default_escalation steps that don't name their own (e.g. :openai). What it means is
|
|
18
|
+
# up to the adapter: the RubyLLM adapter needs it only for models missing from RubyLLM's registry, and the
|
|
19
|
+
# OpenAI-compatible adapter ignores it.
|
|
19
20
|
attr_accessor :default_provider
|
|
20
21
|
|
|
21
22
|
# Generation params applied to every call, overridable per class and per method.
|
|
22
|
-
# :temperature and :thinking ({ effort:, budget: })
|
|
23
|
-
#
|
|
23
|
+
# :temperature, :max_output_tokens and :thinking ({ effort:, budget: }) are portable and translated by the
|
|
24
|
+
# adapter for its provider; any other key (top_p, seed, ...) is passed to the provider as-is.
|
|
24
25
|
attr_reader :default_params
|
|
25
26
|
|
|
26
27
|
# How every squishling class that doesn't declare its own harness uses its escalation (see Harness):
|
|
@@ -28,19 +29,37 @@ module Squishling
|
|
|
28
29
|
# its options.
|
|
29
30
|
attr_reader :default_harness
|
|
30
31
|
|
|
32
|
+
# Most output tokens, and most input tokens, one squished call may use across all its requests (nil, the default,
|
|
33
|
+
# sets no limit). A call that has used this many stops with a BudgetExhaustedError instead of making another
|
|
34
|
+
# attempt; overridable per class, method and squish! call (see Budget).
|
|
35
|
+
attr_reader :default_max_total_output_tokens, :default_max_total_input_tokens
|
|
36
|
+
|
|
31
37
|
# Optional Logger for routing and escalation decisions.
|
|
32
38
|
attr_accessor :logger
|
|
33
39
|
|
|
34
|
-
# Optional callable run after
|
|
40
|
+
# Optional callable run after each LLM attempt (narrowed by squawk_on) with the raw output (see Squawk), e.g.
|
|
35
41
|
# ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
|
|
36
42
|
attr_reader :squawk
|
|
37
43
|
|
|
44
|
+
# Which attempts reach the squawk hook: :all (the default), :rejections (attempts that errored), :retries (those
|
|
45
|
+
# plus any accepted attempt after the first), :final (the last attempt the escalation allows), or a list of
|
|
46
|
+
# them. Also settable per class and per method.
|
|
47
|
+
attr_reader :squawk_on
|
|
48
|
+
|
|
38
49
|
# Optional around-style hook wrapped around every provider request (see Observer): an object responding to
|
|
39
50
|
# around(call) { ... }, or :coolhand. nil (the default) uses the Coolhand observer when the coolhand gem is
|
|
40
51
|
# loaded and configured, and nothing otherwise; false never wraps.
|
|
41
52
|
attr_reader :observer
|
|
42
53
|
|
|
54
|
+
# How Squishling reaches a model (see Adapter): :ruby_llm, :openai_compatible, an adapter you registered, a Hash
|
|
55
|
+
# with type: and that adapter's options, or an adapter instance. nil (the default) uses RubyLLM when the ruby_llm
|
|
56
|
+
# gem is available. Built when assigned, so a mistake fails at setup rather than on the first request.
|
|
57
|
+
attr_reader :adapter
|
|
58
|
+
|
|
43
59
|
def initialize
|
|
60
|
+
@adapter = nil
|
|
61
|
+
@detected_adapter = nil
|
|
62
|
+
@adapter_lock = Mutex.new
|
|
44
63
|
@observer = nil
|
|
45
64
|
@default_model = nil
|
|
46
65
|
@default_escalation = nil
|
|
@@ -50,6 +69,7 @@ module Squishling
|
|
|
50
69
|
@default_harness = nil
|
|
51
70
|
@logger = nil
|
|
52
71
|
@squawk = nil
|
|
72
|
+
@squawk_on = :all
|
|
53
73
|
end
|
|
54
74
|
|
|
55
75
|
def default_model=(model)
|
|
@@ -64,10 +84,41 @@ module Squishling
|
|
|
64
84
|
@default_escalation = escalation
|
|
65
85
|
end
|
|
66
86
|
|
|
87
|
+
def adapter=(adapter)
|
|
88
|
+
built = adapter.nil? ? nil : Adapter.build(adapter, "Squishling.configure")
|
|
89
|
+
@adapter_lock.synchronize do
|
|
90
|
+
@adapter = built
|
|
91
|
+
@detected_adapter = nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The adapter requests go through: the configured one, else RubyLLM's (when its gem is available) built on first
|
|
96
|
+
# use. Raises ConfigurationError when there is neither.
|
|
97
|
+
def resolved_adapter
|
|
98
|
+
@adapter_lock.synchronize { @adapter || (@detected_adapter ||= Adapter.detect) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def default_max_total_output_tokens=(tokens)
|
|
102
|
+
@default_max_total_output_tokens = Budget.build("default_max_total_output_tokens", output: tokens)[:output]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def default_max_total_input_tokens=(tokens)
|
|
106
|
+
@default_max_total_input_tokens = Budget.build("default_max_total_input_tokens", input: tokens)[:input]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The configured limits as a Budget level.
|
|
110
|
+
def default_budget
|
|
111
|
+
{ output: default_max_total_output_tokens, input: default_max_total_input_tokens }.compact
|
|
112
|
+
end
|
|
113
|
+
|
|
67
114
|
def squawk=(hook)
|
|
68
115
|
@squawk = Squawk.validate(hook, "Squishling.configure")
|
|
69
116
|
end
|
|
70
117
|
|
|
118
|
+
def squawk_on=(on)
|
|
119
|
+
@squawk_on = Squawk.validate_on(on, "Squishling.configure") || :all
|
|
120
|
+
end
|
|
121
|
+
|
|
71
122
|
def observer=(observer)
|
|
72
123
|
Observer.validate(observer, "Squishling.configure")
|
|
73
124
|
# A named observer is built now, so a missing gem fails at setup rather than on the first request.
|
|
@@ -7,11 +7,14 @@ module Squishling
|
|
|
7
7
|
# Matches the keys a squish! context: Hash may use.
|
|
8
8
|
NAME_KEY = ->(key) { key.is_a?(String) || key.is_a?(Symbol) }
|
|
9
9
|
|
|
10
|
+
# One attempt on RubyLLM's default model, when no level declares a model.
|
|
11
|
+
DEFAULT_STEP = { model: nil, attempts: 1, forward_rejected: true }.freeze
|
|
12
|
+
|
|
10
13
|
attr_reader :klass, :name, :call_context, :handed_off
|
|
11
14
|
|
|
12
15
|
def initialize(klass:, name:, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil,
|
|
13
16
|
provider: nil, params: nil, harness: nil, predicate: nil, fallback: nil, validator: nil, squawk: nil,
|
|
14
|
-
call_context: {}, handed_off: false)
|
|
17
|
+
squawk_on: nil, budget: nil, call_context: {}, handed_off: false)
|
|
15
18
|
@klass = klass
|
|
16
19
|
@name = name
|
|
17
20
|
@purpose = purpose
|
|
@@ -25,6 +28,8 @@ module Squishling
|
|
|
25
28
|
@fallback = fallback
|
|
26
29
|
@validator = validator
|
|
27
30
|
@squawk = squawk
|
|
31
|
+
@squawk_on = squawk_on
|
|
32
|
+
@budget = budget || {}
|
|
28
33
|
@call_context = call_context
|
|
29
34
|
@handed_off = handed_off
|
|
30
35
|
end
|
|
@@ -32,7 +37,7 @@ module Squishling
|
|
|
32
37
|
# This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
|
|
33
38
|
# can't be overridden: the call must still return the method's result type.
|
|
34
39
|
def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
|
|
35
|
-
provider: nil, params: nil, harness: nil)
|
|
40
|
+
provider: nil, params: nil, harness: nil, max_total_output_tokens: nil, max_total_input_tokens: nil)
|
|
36
41
|
call_path = ModelPath.declare(model, escalation, "#{label} squish!")
|
|
37
42
|
raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
|
|
38
43
|
unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
|
|
@@ -42,15 +47,16 @@ module Squishling
|
|
|
42
47
|
appended = Appendices.normalize(append_to_purpose, "#{label} squish!") unless append_to_purpose.nil?
|
|
43
48
|
call_params = params && Params.normalize(params, "#{label} squish! params")
|
|
44
49
|
call_harness = Harness.normalize(harness, "#{label} squish!") unless harness.nil?
|
|
50
|
+
call_budget = Budget.build("#{label} squish!", output: max_total_output_tokens, input: max_total_input_tokens)
|
|
45
51
|
self.class.new(
|
|
46
52
|
klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
|
|
47
|
-
validator: @validator, squawk: @squawk,
|
|
53
|
+
validator: @validator, squawk: @squawk, squawk_on: @squawk_on,
|
|
48
54
|
purpose: purpose || @purpose,
|
|
49
55
|
append_to_purpose: [*@append_to_purpose, *appended],
|
|
50
56
|
model: call_path || @model, provider: call_path ? provider : @provider,
|
|
51
57
|
# merge, not Params.resolve: a nil at the method level must still unset the class's key.
|
|
52
58
|
params: call_params ? (@params || {}).merge(call_params) : @params,
|
|
53
|
-
harness: call_harness || @harness,
|
|
59
|
+
harness: call_harness || @harness, budget: @budget.merge(call_budget),
|
|
54
60
|
call_context: @call_context.merge((context || {}).transform_keys(&:to_sym)),
|
|
55
61
|
handed_off: true # only squish! builds a definition this way; it is not part of a declared one
|
|
56
62
|
)
|
|
@@ -62,7 +68,7 @@ module Squishling
|
|
|
62
68
|
|
|
63
69
|
# The system prompt: the purpose, then each append_to_purpose section.
|
|
64
70
|
def purpose(receiver)
|
|
65
|
-
value =
|
|
71
|
+
value = declared_purpose
|
|
66
72
|
value = receiver.instance_exec(&value) if value.is_a?(Proc)
|
|
67
73
|
return value if value.nil? || value.empty?
|
|
68
74
|
|
|
@@ -79,16 +85,48 @@ module Squishling
|
|
|
79
85
|
end
|
|
80
86
|
|
|
81
87
|
# One ModelPath::Step per attempt, from the first level (method, class, or config) that declares a model
|
|
82
|
-
# or escalation, or a single attempt on
|
|
88
|
+
# or escalation, or a single attempt on the adapter's default model. A provider travels with the model declared
|
|
83
89
|
# at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider. Models
|
|
84
|
-
# tied on priority are rotated by this method's call count (see Rotation).
|
|
85
|
-
|
|
90
|
+
# tied on priority are rotated by this method's call count (see Rotation). Steps whose include_when: is falsy
|
|
91
|
+
# for this call's inputs are left out, so a tier rotates only between its eligible models. advance: false looks
|
|
92
|
+
# at the turn the next call will get without counting this one (a prompt preview).
|
|
93
|
+
def escalation_path(receiver, inputs, advance: true)
|
|
94
|
+
declared, provider = declared_path
|
|
95
|
+
eligible = ModelPath.eligible(declared, receiver, inputs, label)
|
|
96
|
+
# Counted when the declared path rotates (so a call that excludes some models doesn't skew the others'
|
|
97
|
+
# rotation) or when this call's eligible steps do (an excluded rotate: false step no longer pins its tier).
|
|
98
|
+
rotates = ModelPath.rotating?(declared) || ModelPath.rotating?(eligible)
|
|
99
|
+
turn = if rotates
|
|
100
|
+
advance ? Rotation.advance(klass, name) : Rotation.peek(klass, name)
|
|
101
|
+
else
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
ModelPath.steps(eligible, provider:, params:, turn:)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The steps and provider of the first level (method, class, or config) that declares a model or escalation, or
|
|
108
|
+
# the default step, before any call's include_when: filtering.
|
|
109
|
+
def declared_path
|
|
86
110
|
config = Squishling.config
|
|
87
111
|
entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
|
|
88
112
|
[config.default_model_path, config.default_provider]].find(&:first)
|
|
89
|
-
entries
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
[entries || [DEFAULT_STEP], provider]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether a purpose is declared for this method or its class (a Proc one can't be evaluated without a receiver).
|
|
117
|
+
def purpose_declared?
|
|
118
|
+
value = declared_purpose
|
|
119
|
+
value.is_a?(Proc) || !(value.nil? || value.empty?)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The names the method's inputs are sent under (see #bind_arguments), and whether any keyword may be an input:
|
|
123
|
+
# there is no implementation to name them, it takes **, or has a parameter with no name to send it under.
|
|
124
|
+
def input_names
|
|
125
|
+
names = parameters.filter_map { |type, param| input_name(type, param) }
|
|
126
|
+
any_keyword = !implemented? || parameters.any? do |type, param|
|
|
127
|
+
type == :keyrest || (Squawk::POSITIONAL_TYPES.include?(type) && param.nil?)
|
|
128
|
+
end
|
|
129
|
+
[names, any_keyword]
|
|
92
130
|
end
|
|
93
131
|
|
|
94
132
|
# How the escalation is used (see Harness): the method's, the class's, the configured default, or :escalation.
|
|
@@ -101,23 +139,48 @@ module Squishling
|
|
|
101
139
|
Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
|
|
102
140
|
end
|
|
103
141
|
|
|
142
|
+
# The token limits of one call (see Budget): config defaults, overridden limit by limit by the class, then by the
|
|
143
|
+
# method (and a squish! call).
|
|
144
|
+
def budget
|
|
145
|
+
Budget.resolve(Squishling.config.default_budget, klass.squishling_budget, @budget)
|
|
146
|
+
end
|
|
147
|
+
|
|
104
148
|
# Extra output checks run on schema-valid LLM results (see ClassMethods#squish_validate).
|
|
105
149
|
def validator
|
|
106
150
|
@validator || klass.squishling_validator
|
|
107
151
|
end
|
|
108
152
|
|
|
153
|
+
# The squish_fallback handler: the method's, else the class's (nil when there is none).
|
|
154
|
+
def fallback
|
|
155
|
+
@fallback || klass.squishling_fallback
|
|
156
|
+
end
|
|
157
|
+
|
|
109
158
|
# The observability hook: the method's, else the class's, else the configured one. `false` at a level
|
|
110
159
|
# silences the levels above it.
|
|
111
160
|
def squawk
|
|
112
161
|
[@squawk, klass.squishling_squawk, Squishling.config.squawk].find { |hook| !hook.nil? } || nil
|
|
113
162
|
end
|
|
114
163
|
|
|
164
|
+
# Which attempts reach the hook (see Squawk): the method's, else the class's, else the configured one.
|
|
165
|
+
def squawk_on
|
|
166
|
+
[@squawk_on, klass.squishling_squawk_on, Squishling.config.squawk_on].find { |on| !on.nil? }
|
|
167
|
+
end
|
|
168
|
+
|
|
115
169
|
def context_names
|
|
116
170
|
klass.squishling_context_names
|
|
117
171
|
end
|
|
118
172
|
|
|
173
|
+
# The squish_when predicate: the method's, else the class's.
|
|
174
|
+
def predicate
|
|
175
|
+
@predicate || klass.squishling_predicate
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Whether the method has a Ruby implementation (otherwise every call goes to the LLM).
|
|
179
|
+
def implemented?
|
|
180
|
+
!Wrapper.implementation(klass.instance_method(name)).nil?
|
|
181
|
+
end
|
|
182
|
+
|
|
119
183
|
def squish?(receiver, inputs)
|
|
120
|
-
predicate = @predicate || klass.squishling_predicate
|
|
121
184
|
return false unless predicate
|
|
122
185
|
|
|
123
186
|
receiver.instance_exec(**inputs, &predicate) ? true : false
|
|
@@ -128,7 +191,7 @@ module Squishling
|
|
|
128
191
|
def invoke_llm(receiver, inputs)
|
|
129
192
|
Invoker.new(self, receiver, inputs).call
|
|
130
193
|
rescue InvalidOutputError, LLMError => e
|
|
131
|
-
handler =
|
|
194
|
+
handler = fallback
|
|
132
195
|
raise unless handler
|
|
133
196
|
|
|
134
197
|
Squishling.config.logger&.warn("[Squishling] #{label} LLM failed, using fallback: #{e.message}")
|
|
@@ -148,7 +211,7 @@ module Squishling
|
|
|
148
211
|
|
|
149
212
|
attrs = attrs.to_h if attrs.is_a?(Result::Instance)
|
|
150
213
|
data, errors = jsonify_return(attrs)
|
|
151
|
-
errors = schema.validate(data) if errors.empty?
|
|
214
|
+
errors = InvalidOutputError::Detail.from(schema.validate(data), :schema) if errors.empty?
|
|
152
215
|
raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
|
|
153
216
|
|
|
154
217
|
schema.build(data, squished:)
|
|
@@ -174,7 +237,7 @@ module Squishling
|
|
|
174
237
|
unnamed += 1
|
|
175
238
|
end
|
|
176
239
|
when :rest
|
|
177
|
-
bound[
|
|
240
|
+
bound[input_name(type, param)] = positional.shift(positional.size)
|
|
178
241
|
end
|
|
179
242
|
end
|
|
180
243
|
positional.each_with_index { |value, index| bound[:"arg#{unnamed + index}"] = value }
|
|
@@ -188,7 +251,18 @@ module Squishling
|
|
|
188
251
|
def jsonify_return(attrs)
|
|
189
252
|
[Schema.jsonify(attrs), []]
|
|
190
253
|
rescue JSON::GeneratorError, JSON::NestingError => e
|
|
191
|
-
[nil, [e.message]]
|
|
254
|
+
[nil, [InvalidOutputError::Detail.new(e.message, :parse)]]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def declared_purpose
|
|
258
|
+
@purpose || klass.purpose
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def input_name(type, param)
|
|
262
|
+
case type
|
|
263
|
+
when :req, :opt, :key, :keyreq then param
|
|
264
|
+
when :rest then param.nil? || param == :* ? :args : param
|
|
265
|
+
end
|
|
192
266
|
end
|
|
193
267
|
|
|
194
268
|
# The implementation's parameters, beneath the prepended wrappers.
|
data/lib/squishling/errors.rb
CHANGED
|
@@ -7,19 +7,87 @@ module Squishling
|
|
|
7
7
|
# missing API key). Never retried and never passed to squish_fallback.
|
|
8
8
|
class ConfigurationError < Error; end
|
|
9
9
|
|
|
10
|
+
# The provider answered a request with HTTP 400 (or another 4xx that isn't a credentials, model, or redirect
|
|
11
|
+
# problem). Still a ConfigurationError: never retried, escalated, or passed to a fallback. The provider's message is
|
|
12
|
+
# never repeated; the machine-readable status and code its error body carries are, when they are plain tokens
|
|
13
|
+
# (e.g. "INVALID_ARGUMENT", "invalid_request_error"). Both are nil when the body has none.
|
|
14
|
+
class RequestRejectedError < ConfigurationError
|
|
15
|
+
attr_reader :provider_status, :provider_code
|
|
16
|
+
|
|
17
|
+
def initialize(message, provider_status: nil, provider_code: nil)
|
|
18
|
+
@provider_status = provider_status
|
|
19
|
+
@provider_code = provider_code
|
|
20
|
+
super(message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Squishling.verify! found problems in the squished classes' setup. report holds them all (see Verification::Report).
|
|
25
|
+
class VerificationError < ConfigurationError
|
|
26
|
+
attr_reader :report
|
|
27
|
+
|
|
28
|
+
def initialize(report)
|
|
29
|
+
@report = report
|
|
30
|
+
super(report.to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def problems
|
|
34
|
+
report.problems
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
10
38
|
# Output that didn't match the schema (or a squish_validate check): from the LLM after every attempt in the
|
|
11
39
|
# escalation, or from the deterministic path.
|
|
12
40
|
class InvalidOutputError < Error
|
|
13
|
-
#
|
|
14
|
-
|
|
41
|
+
# Added to the message (and the log line) when the last reply was cut off at the output token cap.
|
|
42
|
+
CUTOFF_NOTE = "cut off at the output token limit"
|
|
43
|
+
|
|
44
|
+
# One entry of #error_details: the message and where it came from.
|
|
45
|
+
# :schema a JSON Schema error; it can name a key the model chose (a disallowed additional property)
|
|
46
|
+
# :validator your own squish_validate message, verbatim (so it holds model values only if you interpolate them)
|
|
47
|
+
# :parse a message about a response or return value that didn't parse (not valid JSON, empty,
|
|
48
|
+
# a value JSON can't represent)
|
|
49
|
+
# :squishling any other text Squishling or a custom harness wrote
|
|
50
|
+
Detail = Data.define(:message, :source) do
|
|
51
|
+
# One detail per message, all from the same source.
|
|
52
|
+
def self.from(messages, source) = messages.map { |message| new(message, source) }
|
|
15
53
|
|
|
16
|
-
|
|
17
|
-
|
|
54
|
+
def initialize(message:, source:)
|
|
55
|
+
unless Detail::SOURCES.include?(source)
|
|
56
|
+
raise ArgumentError, "unknown error source #{source.inspect} (expected #{Detail::SOURCES.join(', ')})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Whether the message can contain text the model chose, so a sink with its own control syntax should escape
|
|
63
|
+
# it. Your own squish_validate messages are never flagged, even if you interpolate result values into them.
|
|
64
|
+
def model_derived? = source == :schema
|
|
65
|
+
end
|
|
66
|
+
Detail::SOURCES = %i[schema validator parse squishling].freeze
|
|
67
|
+
|
|
68
|
+
# models: the model ids tried, one per attempt (nil entries mean the adapter's default model). finish_reason: why
|
|
69
|
+
# the provider stopped on the last attempt (a Symbol such as :stop or :max_tokens; nil when unknown or not from
|
|
70
|
+
# an LLM).
|
|
71
|
+
# errors: Strings, or Details; a String counts as :squishling text.
|
|
72
|
+
attr_reader :errors, :error_details, :raw, :attempts, :models, :finish_reason
|
|
73
|
+
|
|
74
|
+
def initialize(errors:, raw: nil, source: "LLM", attempts: nil, models: nil, finish_reason: nil)
|
|
75
|
+
@error_details = errors.map { |error| error.is_a?(Detail) ? error : Detail.new(error.to_s, :squishling) }
|
|
76
|
+
@error_details.freeze
|
|
77
|
+
@errors = @error_details.map(&:message).freeze
|
|
18
78
|
@raw = raw
|
|
19
79
|
@attempts = attempts
|
|
20
80
|
@models = models
|
|
81
|
+
@finish_reason = finish_reason
|
|
21
82
|
tries = attempts ? " after #{attempts} attempt#{'s' unless attempts == 1}" : ""
|
|
22
|
-
|
|
83
|
+
cutoff = " (#{CUTOFF_NOTE})" if truncated?
|
|
84
|
+
super("#{source} output was invalid#{tries}: #{@errors.join('; ')}#{cutoff}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whether the last reply was cut off at the output token cap, so raising max_output_tokens (leaving room for any
|
|
88
|
+
# thinking budget) is the fix rather than changing the prompt or validator.
|
|
89
|
+
def truncated?
|
|
90
|
+
finish_reason == :max_tokens
|
|
23
91
|
end
|
|
24
92
|
end
|
|
25
93
|
|
|
@@ -44,7 +112,24 @@ module Squishling
|
|
|
44
112
|
end
|
|
45
113
|
end
|
|
46
114
|
|
|
47
|
-
# The
|
|
48
|
-
#
|
|
115
|
+
# The call spent its token budget (max_total_output_tokens or max_total_input_tokens) before any attempt was
|
|
116
|
+
# accepted, so no further request was made. limit names the budget that was reached, max its value, and used the
|
|
117
|
+
# tokens spent so far ({ input_tokens:, output_tokens: }). errors starts with the budget line, then the validation
|
|
118
|
+
# errors of the last rejected output, which is in raw (nil when no output had been rejected, e.g. the samples were
|
|
119
|
+
# valid but the judge could not run). Like any InvalidOutputError it goes to squish_fallback.
|
|
120
|
+
class BudgetExhaustedError < InvalidOutputError
|
|
121
|
+
attr_reader :limit, :max, :used
|
|
122
|
+
|
|
123
|
+
def initialize(limit:, max:, used:, errors: [], raw: nil, attempts: nil, models: nil)
|
|
124
|
+
@limit = limit
|
|
125
|
+
@max = max
|
|
126
|
+
@used = used
|
|
127
|
+
spent = used.fetch(:"#{limit.to_s.delete_prefix('max_total_')}")
|
|
128
|
+
super(errors: ["token budget exhausted: #{spent} of #{max} #{limit} used", *errors], raw:, attempts:, models:)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The LLM call itself failed (rate limit, server error, timeout, connection) after the
|
|
133
|
+
# adapter's own HTTP retries. The original exception is available as #cause.
|
|
49
134
|
class LLMError < Error; end
|
|
50
135
|
end
|
|
@@ -4,10 +4,10 @@ module Squishling
|
|
|
4
4
|
class Harness
|
|
5
5
|
# Like Squishling::Harness::Squishsum, but samples that disagree go to a judge, which picks one or rejects both.
|
|
6
6
|
# A judge is one step: a model name or a Hash with the escalation step keys (except priority:), plus
|
|
7
|
-
# type: (:chat, the default, or :judgment for a System One decision model through
|
|
7
|
+
# type: (:chat, the default, or :judgment for a System One decision model through the adapter's judgments) and,
|
|
8
8
|
# for :judgment, min_confidence: (the probability the chosen candidate needs).
|
|
9
9
|
class JudgedSquishsum < Base
|
|
10
|
-
# Request keys RubyLLM's judgment protocols own (model and input are already reserved params).
|
|
10
|
+
# Request keys the RubyLLM adapter's judgment protocols own (model and input are already reserved params).
|
|
11
11
|
JUDGMENT_RESERVED_PARAMS = %i[state questions].freeze
|
|
12
12
|
private_constant :JUDGMENT_RESERVED_PARAMS
|
|
13
13
|
|
|
@@ -4,7 +4,8 @@ module Squishling
|
|
|
4
4
|
class Harness
|
|
5
5
|
# The types a harness option can declare (see Base.option). Each checks a given value and returns it
|
|
6
6
|
# normalized, or raises ConfigurationError, so a bad option fails before any request is made.
|
|
7
|
-
# :step a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected
|
|
7
|
+
# :step a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected:,
|
|
8
|
+
# retry_temperature:)
|
|
8
9
|
# :proc, :string, :symbol, :integer, :boolean
|
|
9
10
|
# A harness with a shape of its own declares the type as a callable instead, which gets the value, the
|
|
10
11
|
# declaration site, and the option's name, and returns the normalized value (or raises ConfigurationError).
|