squishling 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +25 -9
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +53 -18
  6. data/docs/failures.md +123 -18
  7. data/docs/harnesses.md +24 -14
  8. data/docs/measuring-tokens.md +8 -6
  9. data/docs/routing.md +5 -0
  10. data/docs/schemas.md +11 -2
  11. data/docs/testing.md +183 -0
  12. data/docs/verifying.md +84 -0
  13. data/lib/squishling/adapter/error_body.rb +47 -0
  14. data/lib/squishling/adapter.rb +275 -0
  15. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  16. data/lib/squishling/adapters/openai_options.rb +72 -0
  17. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  18. data/lib/squishling/budget.rb +71 -0
  19. data/lib/squishling/class_methods.rb +43 -7
  20. data/lib/squishling/class_registry.rb +63 -0
  21. data/lib/squishling/configuration.rb +57 -6
  22. data/lib/squishling/definition.rb +90 -16
  23. data/lib/squishling/errors.rb +92 -7
  24. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  25. data/lib/squishling/harness/option_types.rb +2 -1
  26. data/lib/squishling/harness/registry.rb +8 -36
  27. data/lib/squishling/invoker.rb +21 -9
  28. data/lib/squishling/judge.rb +16 -3
  29. data/lib/squishling/llm_client.rb +66 -132
  30. data/lib/squishling/model_path.rb +60 -13
  31. data/lib/squishling/named_registry.rb +64 -0
  32. data/lib/squishling/observer.rb +11 -2
  33. data/lib/squishling/output_check.rb +13 -9
  34. data/lib/squishling/params.rb +6 -23
  35. data/lib/squishling/railtie.rb +10 -0
  36. data/lib/squishling/result.rb +37 -16
  37. data/lib/squishling/rotation.rb +5 -0
  38. data/lib/squishling/rspec.rb +18 -0
  39. data/lib/squishling/run/messages.rb +40 -3
  40. data/lib/squishling/run/rejections.rb +43 -0
  41. data/lib/squishling/run.rb +66 -42
  42. data/lib/squishling/schema.rb +10 -9
  43. data/lib/squishling/squawk.rb +33 -3
  44. data/lib/squishling/tasks/squishling.rake +18 -0
  45. data/lib/squishling/testing/adapter.rb +194 -0
  46. data/lib/squishling/testing/checks.rb +39 -0
  47. data/lib/squishling/testing/direct.rb +41 -0
  48. data/lib/squishling/testing/errors.rb +18 -0
  49. data/lib/squishling/testing/fixtures.rb +206 -0
  50. data/lib/squishling/testing/helpers.rb +71 -0
  51. data/lib/squishling/testing/minitest.rb +32 -0
  52. data/lib/squishling/testing/observer.rb +31 -0
  53. data/lib/squishling/testing/reply.rb +60 -0
  54. data/lib/squishling/testing/request.rb +75 -0
  55. data/lib/squishling/testing/rspec.rb +30 -0
  56. data/lib/squishling/testing/script.rb +189 -0
  57. data/lib/squishling/testing/state.rb +73 -0
  58. data/lib/squishling/testing/stubs.rb +112 -0
  59. data/lib/squishling/testing.rb +126 -0
  60. data/lib/squishling/verification.rb +39 -0
  61. data/lib/squishling/verifier.rb +179 -0
  62. data/lib/squishling/version.rb +1 -1
  63. data/lib/squishling.rb +57 -4
  64. metadata +40 -21
@@ -9,7 +9,7 @@ module Squishling
9
9
  # squishling model: "claude-sonnet-5-5", purpose: "...", output_schema: MySchema
10
10
  # model: is a single attempt; escalation: is a list of models tried in order (see ModelPath):
11
11
  # squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
12
- # Pass provider: alongside model: for models missing from RubyLLM's registry, e.g.
12
+ # Pass provider: alongside model: for models missing from RubyLLM's registry (the RubyLLM adapter), e.g.
13
13
  # squishling model: "gpt-7-preview", provider: :openai
14
14
  # provider: needs a model: or escalation: (declared on the class), and a subclass's model never inherits its
15
15
  # parent's provider.
@@ -19,11 +19,19 @@ module Squishling
19
19
  # squishling append_to_purpose: ["The Ruby that handles well-formed input:", self]
20
20
  # harness: chooses how the escalation is used (see Harness):
21
21
  # squishling harness: :judged_squishsum
22
- # squawk: is called after every LLM attempt with the raw output (see Configuration#squawk); false silences
22
+ # max_total_output_tokens:/max_total_input_tokens: cap the tokens one call may use across all its requests
23
+ # (see Budget); false lifts a cap inherited from the parent class or the configuration:
24
+ # squishling max_total_output_tokens: 25_000
25
+ # squawk: is called after each LLM attempt (narrowed by squawk_on:) 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
- @squishling_squawk = Squawk.validate(squawk, to_s) unless squawk.nil?
52
+ declare_squawk(squawk, squawk_on)
53
+ declare_budget(max_total_output_tokens, max_total_input_tokens)
45
54
  self
46
55
  end
47
56
 
57
+ # The token limits declared on this class and its ancestors, limit by limit (see Budget).
58
+ def squishling_budget
59
+ squishling_inherited(:squishling_budget, {}).merge(@squishling_budget || {})
60
+ end
61
+
48
62
  def squishling_squawk
49
63
  squishling_lookup(:@squishling_squawk)
50
64
  end
51
65
 
66
+ def squishling_squawk_on
67
+ squishling_lookup(:@squishling_squawk_on)
68
+ end
69
+
52
70
  # The class's (or nearest ancestor's) model or escalation, as normalized steps.
53
71
  def squishling_model_path
54
72
  squishling_lookup(:@squishling_model_path)
@@ -147,11 +165,14 @@ module Squishling
147
165
  # squish :triage, purpose: "...", escalation: %w[claude-haiku-4-5 claude-sonnet-5-5], when: ->(**) { true },
148
166
  # fallback: ->(error, **) { { priority: "medium" } }, append_to_purpose: [...],
149
167
  # validate: ->(result, **) { "team is required" if result.team.empty? },
150
- # harness: :squishsum, squawk: ->(output:, error:, **) { Tracer.record(output, error) } do
168
+ # harness: :squishsum, max_total_output_tokens: 25_000,
169
+ # squawk: ->(output:, error:, **) { Tracer.record(output, error) },
170
+ # squawk_on: :retries do
151
171
  # string :priority
152
172
  # end
153
173
  def squish(*names, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil, escalation: nil,
154
- provider: nil, params: nil, harness: nil, when: nil, fallback: nil, validate: nil, squawk: nil, &schema_block)
174
+ provider: nil, params: nil, harness: nil, when: nil, fallback: nil, validate: nil, squawk: nil,
175
+ squawk_on: nil, max_total_output_tokens: nil, max_total_input_tokens: nil, &schema_block)
155
176
  schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
156
177
  model = ModelPath.declare(model, escalation, "#{self} squish")
157
178
  raise ConfigurationError, "#{self} squish: provider: needs a model: or escalation:" if provider && !model
@@ -162,8 +183,11 @@ module Squishling
162
183
  append_to_purpose = Appendices.normalize(append_to_purpose, "#{self} squish append_to_purpose")
163
184
  end
164
185
  squawk = Squawk.validate(squawk, "#{self} squish")
186
+ squawk_on = Squawk.validate_on(squawk_on, "#{self} squish")
187
+ budget = Budget.build("#{self} squish", output: max_total_output_tokens, input: max_total_input_tokens)
165
188
  options = { purpose:, append_to_purpose:, output_schema: schema, model:, provider:, params:, harness:,
166
- predicate: binding.local_variable_get(:when), fallback:, validator: validate, squawk: }.compact
189
+ predicate: binding.local_variable_get(:when), fallback:, validator: validate, squawk:,
190
+ squawk_on:, budget: }.compact
167
191
 
168
192
  names.map(&:to_sym).each do |name|
169
193
  (@squishling_methods ||= {})[name] = options
@@ -192,8 +216,20 @@ module Squishling
192
216
 
193
217
  private
194
218
 
219
+ # nil leaves a level unset (so the parent's applies); false for squawk: is stored, to silence it.
220
+ def declare_squawk(squawk, squawk_on)
221
+ @squishling_squawk = Squawk.validate(squawk, to_s) unless squawk.nil?
222
+ @squishling_squawk_on = Squawk.validate_on(squawk_on, "#{self} squishling") unless squawk_on.nil?
223
+ end
224
+
225
+ # Adds the class's own token limits to those it declared before.
226
+ def declare_budget(output, input)
227
+ @squishling_budget = (@squishling_budget || {}).merge(Budget.build(to_s, output:, input:)).freeze
228
+ end
229
+
195
230
  # Each class gets its own prepended wrapper so overrides in subclasses are routed too.
196
231
  def squishling_install_wrapper
232
+ ClassRegistry.register(self)
197
233
  @squishling_wrapper = Wrapper.new
198
234
  prepend(@squishling_wrapper)
199
235
 
@@ -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, RubyLLM's own default model is used.
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). Only
18
- # needed for models missing from RubyLLM's registry.
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: }) map to RubyLLM's with_temperature and
23
- # with_thinking; any other key (top_p, max_tokens, seed, ...) is passed to the provider as-is.
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 every LLM attempt with the raw output (see Squawk), e.g.
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 = @purpose || klass.purpose
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 RubyLLM's default model. A provider travels with the model declared
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
- def escalation_path
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 ||= [{ model: nil, attempts: 1, forward_rejected: true }]
90
- turn = ModelPath.rotating?(entries) ? Rotation.advance(klass, name) : 0
91
- ModelPath.steps(entries, provider:, params:, turn:)
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 = @fallback || klass.squishling_fallback
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[param.nil? || param == :* ? :args : param] = positional.shift(positional.size)
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.
@@ -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
- # models: the model ids tried, one per attempt (nil entries mean RubyLLM's default model).
14
- attr_reader :errors, :raw, :attempts, :models
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
- def initialize(errors:, raw: nil, source: "LLM", attempts: nil, models: nil)
17
- @errors = errors
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
- super("#{source} output was invalid#{tries}: #{errors.join('; ')}")
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 LLM call itself failed (rate limit, server error, timeout, connection) after RubyLLM's own
48
- # HTTP retries. The original exception is available as #cause.
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 RubyLLM.judge) and,
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).