squishling 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +63 -15
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +89 -24
  6. data/docs/coolhand.md +66 -0
  7. data/docs/failures.md +129 -20
  8. data/docs/harnesses.md +186 -48
  9. data/docs/measuring-tokens.md +36 -42
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +7 -2
  12. data/docs/schemas.md +33 -6
  13. data/docs/testing.md +183 -0
  14. data/docs/verifying.md +84 -0
  15. data/lib/squishling/adapter/error_body.rb +47 -0
  16. data/lib/squishling/adapter.rb +275 -0
  17. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  18. data/lib/squishling/adapters/openai_options.rb +72 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +72 -8
  24. data/lib/squishling/definition.rb +95 -17
  25. data/lib/squishling/errors.rb +97 -12
  26. data/lib/squishling/harness/base.rb +136 -0
  27. data/lib/squishling/harness/escalation.rb +22 -0
  28. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  29. data/lib/squishling/harness/option_types.rb +94 -0
  30. data/lib/squishling/harness/registry.rb +41 -0
  31. data/lib/squishling/harness/squishsum.rb +22 -0
  32. data/lib/squishling/harness.rb +25 -99
  33. data/lib/squishling/invoker.rb +28 -233
  34. data/lib/squishling/judge.rb +78 -21
  35. data/lib/squishling/llm_client.rb +73 -109
  36. data/lib/squishling/model_path.rb +117 -27
  37. data/lib/squishling/named_registry.rb +64 -0
  38. data/lib/squishling/observer.rb +109 -0
  39. data/lib/squishling/observers/coolhand.rb +86 -0
  40. data/lib/squishling/output_check.rb +13 -9
  41. data/lib/squishling/params.rb +15 -27
  42. data/lib/squishling/railtie.rb +10 -0
  43. data/lib/squishling/result.rb +37 -16
  44. data/lib/squishling/rotation.rb +31 -0
  45. data/lib/squishling/rspec.rb +18 -0
  46. data/lib/squishling/run/messages.rb +79 -0
  47. data/lib/squishling/run/rejections.rb +43 -0
  48. data/lib/squishling/run.rb +320 -0
  49. data/lib/squishling/schema.rb +10 -9
  50. data/lib/squishling/squawk.rb +33 -3
  51. data/lib/squishling/tasks/squishling.rake +18 -0
  52. data/lib/squishling/testing/adapter.rb +194 -0
  53. data/lib/squishling/testing/checks.rb +39 -0
  54. data/lib/squishling/testing/direct.rb +41 -0
  55. data/lib/squishling/testing/errors.rb +18 -0
  56. data/lib/squishling/testing/fixtures.rb +206 -0
  57. data/lib/squishling/testing/helpers.rb +71 -0
  58. data/lib/squishling/testing/minitest.rb +32 -0
  59. data/lib/squishling/testing/observer.rb +31 -0
  60. data/lib/squishling/testing/reply.rb +60 -0
  61. data/lib/squishling/testing/request.rb +75 -0
  62. data/lib/squishling/testing/rspec.rb +30 -0
  63. data/lib/squishling/testing/script.rb +189 -0
  64. data/lib/squishling/testing/state.rb +73 -0
  65. data/lib/squishling/testing/stubs.rb +112 -0
  66. data/lib/squishling/testing.rb +126 -0
  67. data/lib/squishling/verification.rb +39 -0
  68. data/lib/squishling/verifier.rb +179 -0
  69. data/lib/squishling/version.rb +1 -1
  70. data/lib/squishling.rb +89 -4
  71. metadata +53 -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,28 +14,53 @@ 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):
27
- # :escalation (the default), :squishsum, :judged_squishsum, :ensemble, :judged_ensemble, or a Hash with
28
- # type: and its options.
28
+ # :escalation (the default), :squishsum, :judged_squishsum, a harness you registered, or a Hash with type: and
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
+
49
+ # Optional around-style hook wrapped around every provider request (see Observer): an object responding to
50
+ # around(call) { ... }, or :coolhand. nil (the default) uses the Coolhand observer when the coolhand gem is
51
+ # loaded and configured, and nothing otherwise; false never wraps.
52
+ attr_reader :observer
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
+
38
59
  def initialize
60
+ @adapter = nil
61
+ @detected_adapter = nil
62
+ @adapter_lock = Mutex.new
63
+ @observer = nil
39
64
  @default_model = nil
40
65
  @default_escalation = nil
41
66
  @default_model_path = nil
@@ -44,6 +69,7 @@ module Squishling
44
69
  @default_harness = nil
45
70
  @logger = nil
46
71
  @squawk = nil
72
+ @squawk_on = :all
47
73
  end
48
74
 
49
75
  def default_model=(model)
@@ -58,10 +84,48 @@ module Squishling
58
84
  @default_escalation = escalation
59
85
  end
60
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
+
61
114
  def squawk=(hook)
62
115
  @squawk = Squawk.validate(hook, "Squishling.configure")
63
116
  end
64
117
 
118
+ def squawk_on=(on)
119
+ @squawk_on = Squawk.validate_on(on, "Squishling.configure") || :all
120
+ end
121
+
122
+ def observer=(observer)
123
+ Observer.validate(observer, "Squishling.configure")
124
+ # A named observer is built now, so a missing gem fails at setup rather than on the first request.
125
+ Observer.resolve(observer) if observer.is_a?(Symbol)
126
+ @observer = observer
127
+ end
128
+
65
129
  def default_params=(params)
66
130
  @default_params = Params.normalize(params, "default_params")
67
131
  end
@@ -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
- attr_reader :klass, :name, :call_context
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
+
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: {})
17
+ squawk_on: nil, budget: nil, call_context: {}, handed_off: false)
15
18
  @klass = klass
16
19
  @name = name
17
20
  @purpose = purpose
@@ -25,13 +28,16 @@ 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
34
+ @handed_off = handed_off
29
35
  end
30
36
 
31
37
  # This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
32
38
  # can't be overridden: the call must still return the method's result type.
33
39
  def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
34
- provider: nil, params: nil, harness: nil)
40
+ provider: nil, params: nil, harness: nil, max_total_output_tokens: nil, max_total_input_tokens: nil)
35
41
  call_path = ModelPath.declare(model, escalation, "#{label} squish!")
36
42
  raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
37
43
  unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
@@ -41,16 +47,18 @@ module Squishling
41
47
  appended = Appendices.normalize(append_to_purpose, "#{label} squish!") unless append_to_purpose.nil?
42
48
  call_params = params && Params.normalize(params, "#{label} squish! params")
43
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)
44
51
  self.class.new(
45
52
  klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
46
- validator: @validator, squawk: @squawk,
53
+ validator: @validator, squawk: @squawk, squawk_on: @squawk_on,
47
54
  purpose: purpose || @purpose,
48
55
  append_to_purpose: [*@append_to_purpose, *appended],
49
56
  model: call_path || @model, provider: call_path ? provider : @provider,
50
57
  # merge, not Params.resolve: a nil at the method level must still unset the class's key.
51
58
  params: call_params ? (@params || {}).merge(call_params) : @params,
52
- harness: call_harness || @harness,
53
- call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
59
+ harness: call_harness || @harness, budget: @budget.merge(call_budget),
60
+ call_context: @call_context.merge((context || {}).transform_keys(&:to_sym)),
61
+ handed_off: true # only squish! builds a definition this way; it is not part of a declared one
54
62
  )
55
63
  end
56
64
 
@@ -60,7 +68,7 @@ module Squishling
60
68
 
61
69
  # The system prompt: the purpose, then each append_to_purpose section.
62
70
  def purpose(receiver)
63
- value = @purpose || klass.purpose
71
+ value = declared_purpose
64
72
  value = receiver.instance_exec(&value) if value.is_a?(Proc)
65
73
  return value if value.nil? || value.empty?
66
74
 
@@ -77,14 +85,48 @@ module Squishling
77
85
  end
78
86
 
79
87
  # One ModelPath::Step per attempt, from the first level (method, class, or config) that declares a model
80
- # or escalation, or a single attempt on RubyLLM's default model. A provider travels with the model declared
81
- # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider.
82
- def escalation_path
88
+ # or escalation, or a single attempt on the adapter's default model. A provider travels with the model declared
89
+ # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider. Models
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
83
110
  config = Squishling.config
84
111
  entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
85
112
  [config.default_model_path, config.default_provider]].find(&:first)
86
- entries ||= [{ model: nil, attempts: 1, forward_rejected: true }]
87
- ModelPath.steps(entries, provider:, params:)
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]
88
130
  end
89
131
 
90
132
  # How the escalation is used (see Harness): the method's, the class's, the configured default, or :escalation.
@@ -97,23 +139,48 @@ module Squishling
97
139
  Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
98
140
  end
99
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
+
100
148
  # Extra output checks run on schema-valid LLM results (see ClassMethods#squish_validate).
101
149
  def validator
102
150
  @validator || klass.squishling_validator
103
151
  end
104
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
+
105
158
  # The observability hook: the method's, else the class's, else the configured one. `false` at a level
106
159
  # silences the levels above it.
107
160
  def squawk
108
161
  [@squawk, klass.squishling_squawk, Squishling.config.squawk].find { |hook| !hook.nil? } || nil
109
162
  end
110
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
+
111
169
  def context_names
112
170
  klass.squishling_context_names
113
171
  end
114
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
+
115
183
  def squish?(receiver, inputs)
116
- predicate = @predicate || klass.squishling_predicate
117
184
  return false unless predicate
118
185
 
119
186
  receiver.instance_exec(**inputs, &predicate) ? true : false
@@ -124,7 +191,7 @@ module Squishling
124
191
  def invoke_llm(receiver, inputs)
125
192
  Invoker.new(self, receiver, inputs).call
126
193
  rescue InvalidOutputError, LLMError => e
127
- handler = @fallback || klass.squishling_fallback
194
+ handler = fallback
128
195
  raise unless handler
129
196
 
130
197
  Squishling.config.logger&.warn("[Squishling] #{label} LLM failed, using fallback: #{e.message}")
@@ -144,7 +211,7 @@ module Squishling
144
211
 
145
212
  attrs = attrs.to_h if attrs.is_a?(Result::Instance)
146
213
  data, errors = jsonify_return(attrs)
147
- errors = schema.validate(data) if errors.empty?
214
+ errors = InvalidOutputError::Detail.from(schema.validate(data), :schema) if errors.empty?
148
215
  raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
149
216
 
150
217
  schema.build(data, squished:)
@@ -170,7 +237,7 @@ module Squishling
170
237
  unnamed += 1
171
238
  end
172
239
  when :rest
173
- bound[param.nil? || param == :* ? :args : param] = positional.shift(positional.size)
240
+ bound[input_name(type, param)] = positional.shift(positional.size)
174
241
  end
175
242
  end
176
243
  positional.each_with_index { |value, index| bound[:"arg#{unnamed + index}"] = value }
@@ -184,7 +251,18 @@ module Squishling
184
251
  def jsonify_return(attrs)
185
252
  [Schema.jsonify(attrs), []]
186
253
  rescue JSON::GeneratorError, JSON::NestingError => e
187
- [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
188
266
  end
189
267
 
190
268
  # The implementation's parameters, beneath the prepended wrappers.
@@ -7,27 +7,95 @@ 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
 
26
- # A sampling harness's samples (squishsum or ensemble) were valid but didn't agree, and either no judge was declared
27
- # (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates holds the two
28
- # typed results, so a squish_fallback can still return one of them, and raw both as Hashes. models names the
29
- # model behind each role (sample a, sample b, then the judge if one ran), not one entry per attempt; attempts
30
- # is nil.
94
+ # A harness's samples (the squishsum harnesses', or a custom one's) were valid but didn't agree, and either no
95
+ # judge was declared (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates
96
+ # holds the typed results, so a squish_fallback can still return one of them, and raw all of them as Hashes.
97
+ # models names the model behind each role (sample a, sample b, then the judge if one ran), not one entry per
98
+ # attempt; attempts is nil.
31
99
  class DisagreementError < InvalidOutputError
32
100
  attr_reader :candidates, :verdict, :reason
33
101
 
@@ -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