squishling 0.1.0 → 0.2.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.
@@ -2,12 +2,20 @@
2
2
 
3
3
  module Squishling
4
4
  class Configuration
5
- # Model used by every squishling class that doesn't declare its own.
6
- # When nil, RubyLLM's own default model is used.
7
- attr_accessor :default_model
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.
7
+ attr_reader :default_model
8
8
 
9
- # Provider for default_model (e.g. :openai). Only needed for models missing from
10
- # RubyLLM's registry.
9
+ # Models tried in order by every squishling class that doesn't declare its own, e.g.
10
+ # [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
11
+ # See ModelPath. default_model and default_escalation set the same thing: assigning one clears the other.
12
+ attr_reader :default_escalation
13
+
14
+ # The validated default_model or default_escalation steps (nil when neither is set).
15
+ attr_reader :default_model_path
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.
11
19
  attr_accessor :default_provider
12
20
 
13
21
  # Generation params applied to every call, overridable per class and per method.
@@ -15,22 +23,44 @@ module Squishling
15
23
  # with_thinking; any other key (top_p, max_tokens, seed, ...) is passed to the provider as-is.
16
24
  attr_reader :default_params
17
25
 
18
- # How many times to re-ask the LLM when its output fails schema validation.
19
- attr_accessor :max_retries
20
-
21
- # Optional Logger for routing decisions.
26
+ # Optional Logger for routing and escalation decisions.
22
27
  attr_accessor :logger
23
28
 
24
29
  def initialize
25
30
  @default_model = nil
31
+ @default_escalation = nil
32
+ @default_model_path = nil
26
33
  @default_provider = nil
27
34
  @default_params = {}
28
- @max_retries = 1
29
35
  @logger = nil
30
36
  end
31
37
 
38
+ def default_model=(model)
39
+ @default_model_path = ModelPath.from_model(model, "default_model")
40
+ @default_escalation = nil
41
+ @default_model = model
42
+ end
43
+
44
+ def default_escalation=(escalation)
45
+ @default_model_path = ModelPath.from_escalation(escalation, "default_escalation")
46
+ @default_model = nil
47
+ @default_escalation = escalation
48
+ end
49
+
32
50
  def default_params=(params)
33
51
  @default_params = Params.normalize(params, "default_params")
34
52
  end
53
+
54
+ MAX_RETRIES_REMOVED = "max_retries was removed; use default_escalation (or a class/method escalation:) " \
55
+ "with attempts:, e.g. [{ model: \"claude-haiku-4-5\", attempts: 2 }]"
56
+
57
+ # Removed: the escalation decides how many attempts run.
58
+ def max_retries
59
+ raise ConfigurationError, MAX_RETRIES_REMOVED
60
+ end
61
+
62
+ def max_retries=(_value)
63
+ raise ConfigurationError, MAX_RETRIES_REMOVED
64
+ end
35
65
  end
36
66
  end
@@ -10,7 +10,7 @@ module Squishling
10
10
  attr_reader :klass, :name, :call_context
11
11
 
12
12
  def initialize(klass:, name:, instructions: nil, append_instructions: nil, output_schema: nil, model: nil,
13
- provider: nil, params: nil, predicate: nil, fallback: nil, call_context: {})
13
+ provider: nil, params: nil, predicate: nil, fallback: nil, validator: nil, call_context: {})
14
14
  @klass = klass
15
15
  @name = name
16
16
  @instructions = instructions
@@ -21,13 +21,16 @@ module Squishling
21
21
  @params = params
22
22
  @predicate = predicate
23
23
  @fallback = fallback
24
+ @validator = validator
24
25
  @call_context = call_context
25
26
  end
26
27
 
27
- # This definition with one call's overrides on top. The output schema and predicate can't be overridden:
28
- # the call must still return the method's result type.
29
- def for_call(instructions: nil, append_instructions: nil, context: nil, model: nil, provider: nil, params: nil)
30
- raise ConfigurationError, "#{label}: squish! provider: needs a model:" if provider && !model
28
+ # This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
29
+ # can't be overridden: the call must still return the method's result type.
30
+ def for_call(instructions: nil, append_instructions: nil, context: nil, model: nil, escalation: nil,
31
+ provider: nil, params: nil)
32
+ call_path = ModelPath.declare(model, escalation, "#{label} squish!")
33
+ raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
31
34
  unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
32
35
  raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
33
36
  end
@@ -36,9 +39,10 @@ module Squishling
36
39
  call_params = params && Params.normalize(params, "#{label} squish! params")
37
40
  self.class.new(
38
41
  klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
42
+ validator: @validator,
39
43
  instructions: instructions || @instructions,
40
44
  append_instructions: [*@append_instructions, *appended],
41
- model: model || @model, provider: model ? provider : @provider,
45
+ model: call_path || @model, provider: call_path ? provider : @provider,
42
46
  # merge, not Params.resolve: a nil at the method level must still unset the class's key.
43
47
  params: call_params ? (@params || {}).merge(call_params) : @params,
44
48
  call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
@@ -67,20 +71,15 @@ module Squishling
67
71
  raw && Schema.for(raw)
68
72
  end
69
73
 
70
- def model
71
- model_and_provider.first
72
- end
73
-
74
- def provider
75
- model_and_provider.last
76
- end
77
-
78
- # A provider travels with the model declared at the same level (method, class, or config),
79
- # so a per-method Anthropic model never inherits a class-level OpenAI provider.
80
- def model_and_provider
74
+ # One ModelPath::Step per attempt, from the first level (method, class, or config) that declares a model
75
+ # or escalation, or a single attempt on RubyLLM's default model. A provider travels with the model declared
76
+ # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider.
77
+ def escalation_path
81
78
  config = Squishling.config
82
- [[@model, @provider], [klass.squishling_model, klass.squishling_provider],
83
- [config.default_model, config.default_provider]].find(&:first) || [nil, nil]
79
+ entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
80
+ [config.default_model_path, config.default_provider]].find(&:first)
81
+ entries ||= [{ model: nil, attempts: 1 }]
82
+ ModelPath.steps(entries, provider:, params:)
84
83
  end
85
84
 
86
85
  # Generation params: config defaults, overridden key by key by the class, then by the method.
@@ -88,6 +87,11 @@ module Squishling
88
87
  Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
89
88
  end
90
89
 
90
+ # Extra output checks run on schema-valid LLM results (see ClassMethods#squish_validate).
91
+ def validator
92
+ @validator || klass.squishling_validator
93
+ end
94
+
91
95
  def context_names
92
96
  klass.squishling_context_names
93
97
  end
@@ -128,20 +132,30 @@ module Squishling
128
132
  schema.build(data, squished:)
129
133
  end
130
134
 
131
- # Map positional and keyword arguments onto the original method's parameter names.
135
+ # Map positional and keyword arguments onto the original method's parameter names. A positional with no
136
+ # name (a destructuring parameter, or one beyond the declared parameters) is sent as arg0, arg1, ...
132
137
  def bind_arguments(args, kwargs)
133
138
  positional = args.dup
134
139
  bound = {}
140
+ unnamed = 0
135
141
 
136
142
  parameters.each do |type, param|
137
143
  case type
138
144
  when :req, :opt
139
- bound[param] = positional.shift unless positional.empty? || param.nil?
145
+ next if positional.empty?
146
+
147
+ value = positional.shift
148
+ if param
149
+ bound[param] = value
150
+ else
151
+ bound[:"arg#{unnamed}"] = value
152
+ unnamed += 1
153
+ end
140
154
  when :rest
141
155
  bound[param.nil? || param == :* ? :args : param] = positional.shift(positional.size)
142
156
  end
143
157
  end
144
- positional.each_with_index { |value, index| bound[:"arg#{index}"] = value }
158
+ positional.each_with_index { |value, index| bound[:"arg#{unnamed + index}"] = value }
145
159
 
146
160
  bound.merge(kwargs)
147
161
  end
@@ -7,16 +7,19 @@ module Squishling
7
7
  # missing API key). Never retried and never passed to squish_fallback.
8
8
  class ConfigurationError < Error; end
9
9
 
10
- # Output that didn't match the schema: from the LLM after all retries, or from the deterministic path.
10
+ # Output that didn't match the schema (or a squish_validate check): from the LLM after every attempt in the
11
+ # escalation, or from the deterministic path.
11
12
  class InvalidOutputError < Error
12
- attr_reader :errors, :raw, :attempts
13
+ # models: the model ids tried, one per attempt (nil entries mean RubyLLM's default model).
14
+ attr_reader :errors, :raw, :attempts, :models
13
15
 
14
- def initialize(errors:, raw: nil, source: "LLM", attempts: nil)
16
+ def initialize(errors:, raw: nil, source: "LLM", attempts: nil, models: nil)
15
17
  @errors = errors
16
18
  @raw = raw
17
19
  @attempts = attempts
20
+ @models = models
18
21
  tries = attempts ? " after #{attempts} attempt#{'s' unless attempts == 1}" : ""
19
- super("#{source} output did not match the output schema#{tries}: #{errors.join('; ')}")
22
+ super("#{source} output was invalid#{tries}: #{errors.join('; ')}")
20
23
  end
21
24
  end
22
25
 
@@ -14,68 +14,97 @@ module Squishling
14
14
  @inputs = inputs
15
15
  end
16
16
 
17
+ # Makes each attempt of the escalation (Definition#escalation_path) until an output passes the schema and
18
+ # the squish_validate check. Consecutive attempts on the same step continue the same conversation, so the
19
+ # model sees what it got wrong; a new step starts a fresh chat told about the last rejected output.
17
20
  def call
18
- instructions = @definition.instructions(@receiver)
19
- schema = @definition.schema
20
- raise ConfigurationError, "#{@definition.label} has no instructions" if instructions.nil? || instructions.empty?
21
- raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
22
-
23
- chat = build_chat
24
- chat.with_instructions("#{instructions}\n\n#{INPUT_NOTE}")
25
- chat.with_schema(schema.llm_schema)
26
- apply_params(chat)
27
-
28
- response = ask(chat, JSON.generate(payload))
29
- attempts = 0
30
- loop do
31
- attempts += 1
32
- data, errors = parse(response.content)
33
- errors = schema.validate(data) if errors.empty?
34
- return schema.build(data, squished: true) if errors.empty?
35
-
36
- if attempts > Squishling.config.max_retries
37
- raise InvalidOutputError.new(errors:, raw: response.content, attempts:)
21
+ @instructions = @definition.instructions(@receiver)
22
+ @schema = @definition.schema
23
+ raise ConfigurationError, "#{@definition.label} has no instructions" if @instructions.nil? || @instructions.empty?
24
+ raise ConfigurationError, "#{@definition.label} has no output_schema" unless @schema
25
+
26
+ path = @definition.escalation_path
27
+ input = JSON.generate(payload)
28
+ chat = nil
29
+ rejected = nil # [raw content, errors] of the last invalid output
30
+ models = []
31
+
32
+ path.each.with_index(1) do |step, attempt|
33
+ models << step.model
34
+ last = attempt == path.size
35
+ if chat && step == path[attempt - 2]
36
+ message = retry_message(rejected.last)
37
+ else
38
+ chat = start_chat(step)
39
+ message = rejected ? escalation_message(input, *rejected) : input
38
40
  end
39
41
 
40
- response = ask(chat, retry_message(errors))
42
+ begin
43
+ response = ask(chat, message, step)
44
+ rescue LLMError => e
45
+ raise if last
46
+
47
+ # The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
48
+ chat = nil
49
+ log_failure(attempt, path, e.message)
50
+ next
51
+ end
52
+
53
+ result, errors = check(response.content)
54
+ return result if errors.empty?
55
+
56
+ raise InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models:) if last
57
+
58
+ rejected = [response.content, errors]
59
+ log_failure(attempt, path, errors.join("; "))
41
60
  end
42
61
  end
43
62
 
44
63
  private
45
64
 
46
- def build_chat
47
- RubyLLM.chat(**chat_options)
65
+ def start_chat(step)
66
+ chat = build_chat(step)
67
+ chat.with_instructions("#{@instructions}\n\n#{INPUT_NOTE}")
68
+ chat.with_schema(@schema.llm_schema)
69
+ apply_params(chat, step.params)
70
+ chat
71
+ end
72
+
73
+ def build_chat(step)
74
+ RubyLLM.chat(**chat_options(step))
48
75
  rescue RubyLLM::ModelNotFoundError, RubyLLM::ConfigurationError => e
49
76
  raise ConfigurationError, "#{@definition.label}: #{e.message}"
50
77
  end
51
78
 
52
79
  # RubyLLM validates some settings locally (e.g. an impossible thinking budget for the model)
53
80
  # and raises ArgumentError before any request is sent.
54
- def apply_params(chat)
55
- Params.apply(chat, @definition.params)
81
+ def apply_params(chat, params)
82
+ Params.apply(chat, params)
56
83
  rescue ArgumentError => e
57
- raise ConfigurationError, "#{@definition.label}: invalid params #{@definition.params.inspect} (#{e.message})"
84
+ raise ConfigurationError, "#{@definition.label}: invalid params #{params.inspect} (#{e.message})"
58
85
  end
59
86
 
60
- # Transient HTTP failures are already retried by RubyLLM (config.max_retries); anything that
61
- # still fails is surfaced as an LLMError rather than retried again here. A 400 means the request
62
- # we built is invalid (an unsupported param, a schema the provider rejects), so it's a setup
63
- # mistake: a fallback would otherwise hide it on every call.
64
- def ask(chat, message)
87
+ # Transient HTTP failures are already retried by RubyLLM (RubyLLM.config.max_retries); anything
88
+ # that still fails is surfaced as an LLMError, which moves on to the next attempt of the
89
+ # escalation (or propagates from the last one). A 400 means the request we built is invalid (an
90
+ # unsupported param, a schema the provider rejects), so it's a setup mistake: escalating or a
91
+ # fallback would otherwise hide it on every call.
92
+ def ask(chat, message, step)
65
93
  chat.ask(message)
66
94
  rescue RubyLLM::ConfigurationError, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
67
95
  raise ConfigurationError, "#{@definition.label}: #{e.class}: #{e.message}"
68
96
  rescue RubyLLM::BadRequestError => e
69
- raise ConfigurationError, "#{@definition.label}: the provider rejected the request (#{e.message})#{params_hint}"
97
+ raise ConfigurationError,
98
+ "#{@definition.label}: the provider rejected the request (#{e.message})#{params_hint(step.params)}"
70
99
  rescue RubyLLM::Error, Faraday::Error => e
71
100
  raise LLMError, "#{@definition.label}: #{e.class}: #{e.message}"
72
101
  end
73
102
 
74
103
  # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
75
104
  # provider is named, so RubyLLM is told to assume they exist.
76
- def chat_options
77
- model = @definition.model
78
- provider = @definition.provider
105
+ def chat_options(step)
106
+ model = step.model
107
+ provider = step.provider
79
108
  options = { model:, provider: }.compact
80
109
  options[:assume_model_exists] = true if model && provider && !known_model?(model, provider)
81
110
  options
@@ -88,8 +117,7 @@ module Squishling
88
117
  false
89
118
  end
90
119
 
91
- def params_hint
92
- params = @definition.params
120
+ def params_hint(params)
93
121
  return "" if params.empty?
94
122
 
95
123
  ". Check params #{params.inspect}; reasoning models often reject sampling params such as temperature and top_p"
@@ -143,9 +171,75 @@ module Squishling
143
171
  text[/\A\s*```(?:json)?\s*\n(.*?)\n\s*```\s*\z/m, 1] || text
144
172
  end
145
173
 
174
+ # Parses and validates a response: [typed result, []] when it passes, [nil or result, errors] otherwise.
175
+ def check(content)
176
+ data, errors = parse(content)
177
+ errors = @schema.validate(data) if errors.empty?
178
+ return [nil, errors] if errors.any?
179
+
180
+ result = @schema.build(data, squished: true)
181
+ [result, validator_errors(result)]
182
+ end
183
+
184
+ # Runs the squish_validate check, if any. Exceptions it raises are the user's own and propagate as-is.
185
+ def validator_errors(result)
186
+ validator = @definition.validator
187
+ return [] unless validator
188
+
189
+ value = @receiver.instance_exec(result, **@inputs, &validator)
190
+ case value
191
+ when nil, true then []
192
+ when false then ["the output was rejected by squish_validate"]
193
+ when String, Array then Array(value).flatten.compact.map(&:to_s).reject { |message| message.strip.empty? }
194
+ else
195
+ return contract_errors(value) if value.respond_to?(:success?) && value.respond_to?(:errors)
196
+
197
+ raise ConfigurationError, "#{@definition.label}: squish_validate must return nil, true, false, a String, " \
198
+ "an Array of Strings, or a validation result, got #{value.class}"
199
+ end
200
+ end
201
+
202
+ # A dry-validation style result: errors.to_h is { key => ["message", ...] }, nested for nested keys.
203
+ def contract_errors(value)
204
+ return [] if value.success?
205
+
206
+ errors = value.errors
207
+ errors = errors.to_h if !errors.is_a?(Array) && errors.respond_to?(:to_h)
208
+ messages = errors.is_a?(Hash) ? flatten_messages(errors) : Array(errors).map(&:to_s)
209
+ messages.empty? ? ["the output was rejected by squish_validate"] : messages
210
+ end
211
+
212
+ def flatten_messages(errors, prefix = nil)
213
+ errors.flat_map do |key, value|
214
+ path = [prefix, key].compact.join(".")
215
+ next flatten_messages(value, path) if value.is_a?(Hash)
216
+
217
+ Array(value).map { |message| path.empty? ? message.to_s : "#{path} #{message}" }
218
+ end
219
+ end
220
+
146
221
  def retry_message(errors)
147
- "Your previous response did not match the required schema:\n- #{errors.join("\n- ")}\n" \
148
- "Respond again with corrected JSON only."
222
+ "Your previous response was rejected:\n- #{errors.join("\n- ")}\nRespond again with corrected JSON only."
223
+ end
224
+
225
+ # The first message to a fresh chat after an earlier model's output was rejected.
226
+ def escalation_message(input, raw, errors)
227
+ previous = raw.is_a?(String) ? raw : JSON.generate(raw)
228
+ previous = "(an empty response)" if previous.strip.empty? || raw.nil?
229
+ "#{input}\n\nA previous attempt at this request returned:\n#{previous}\n" \
230
+ "It was rejected:\n- #{errors.join("\n- ")}\nRespond with corrected JSON only."
231
+ end
232
+
233
+ def log_failure(attempt, path, reason)
234
+ step = path[attempt - 1]
235
+ next_step = path[attempt]
236
+ action = next_step == step ? "retrying" : "escalating to #{model_name(next_step)}"
237
+ Squishling.config.logger&.warn("[Squishling] #{@definition.label} attempt #{attempt} of #{path.size} " \
238
+ "(#{model_name(step)}) failed, #{action}: #{reason}")
239
+ end
240
+
241
+ def model_name(step)
242
+ step.model || "RubyLLM default model"
149
243
  end
150
244
  end
151
245
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # The models a squished call tries, in order. Declared either as one model (model: "claude-haiku-4-5", a
5
+ # single attempt) or as an escalation: an Array of steps, each a model name or a Hash:
6
+ # escalation: [
7
+ # { model: "claude-haiku-4-5", attempts: 2 },
8
+ # "claude-sonnet-5-5",
9
+ # { model: "claude-opus-5-5", params: { thinking: { effort: :high } } }
10
+ # ]
11
+ # attempts: (default 1) retries a step; order: (all steps or none, unique, lowest first) makes the order
12
+ # explicit instead of positional.
13
+ module ModelPath
14
+ # One attempt: the model, its provider, and the fully resolved generation params.
15
+ Step = Data.define(:model, :provider, :params)
16
+
17
+ STEP_KEYS = %i[model provider params attempts order].freeze
18
+
19
+ module_function
20
+
21
+ # A single model: model:/default_model. Returns a one-step path, or nil when not declared.
22
+ def from_model(model, label)
23
+ return nil if model.nil?
24
+
25
+ if model.is_a?(Array) || model.is_a?(Hash)
26
+ raise ConfigurationError, "#{label} takes one model; use escalation: [...] to try several in order"
27
+ end
28
+
29
+ [normalize_step(model, label)].freeze
30
+ end
31
+
32
+ # An escalation: escalation:/default_escalation. Returns the steps in order, or nil when not declared.
33
+ def from_escalation(escalation, label)
34
+ return nil if escalation.nil?
35
+ unless escalation.is_a?(Array)
36
+ raise ConfigurationError, "#{label} must be an Array of steps, got #{escalation.class}"
37
+ end
38
+ raise ConfigurationError, "#{label} can't be empty" if escalation.empty?
39
+
40
+ ordered(escalation.map { |step| normalize_step(step, label) }, label).freeze
41
+ end
42
+
43
+ # model: and escalation: both describe what to try, so a level may declare only one of them.
44
+ def declare(model, escalation, label)
45
+ if !model.nil? && !escalation.nil?
46
+ raise ConfigurationError, "#{label}: declare model: or escalation:, not both (put the model in the escalation)"
47
+ end
48
+
49
+ from_model(model, "#{label} model") || from_escalation(escalation, "#{label} escalation")
50
+ end
51
+
52
+ # One Step per attempt. The level's provider applies to steps that don't name their own, and each
53
+ # step's params are merged over the level-resolved params.
54
+ def steps(path, provider:, params:)
55
+ path.flat_map do |step|
56
+ attempt = Step.new(model: step[:model], provider: step[:provider] || provider,
57
+ params: Params.resolve(params, step[:params]))
58
+ [attempt] * step[:attempts]
59
+ end
60
+ end
61
+
62
+ def ordered(steps, label)
63
+ numbered = steps.count { |step| step.key?(:order) }
64
+ return steps.map { |step| step.except(:order) } if numbered.zero?
65
+
66
+ if numbered < steps.size
67
+ raise ConfigurationError, "#{label}: give every step an order: or none (#{numbered} of #{steps.size} have one)"
68
+ end
69
+
70
+ orders = steps.map { |step| step[:order] }
71
+ duplicates = orders.tally.select { |_, count| count > 1 }.keys
72
+ raise ConfigurationError, "#{label}: duplicate order: #{duplicates.join(', ')}" if duplicates.any?
73
+
74
+ steps.sort_by { |step| step[:order] }.map { |step| step.except(:order) }
75
+ end
76
+
77
+ def normalize_step(step, label)
78
+ case step
79
+ when String, Symbol then { model: model_name(step, label), provider: nil, params: nil, attempts: 1 }
80
+ when Hash then normalize_hash(step, label)
81
+ else
82
+ raise ConfigurationError, "#{label}: each step must be a model name or a Hash with model:, got #{step.inspect}"
83
+ end
84
+ end
85
+
86
+ def normalize_hash(step, label)
87
+ step = step.transform_keys(&:to_sym)
88
+ unknown = step.keys - STEP_KEYS
89
+ raise ConfigurationError, "#{label}: unknown step option(s) #{unknown.join(', ')}" if unknown.any?
90
+ unless step[:model].is_a?(String) || step[:model].is_a?(Symbol)
91
+ raise ConfigurationError, "#{label}: a step needs a model: name, got #{step.inspect}"
92
+ end
93
+
94
+ model = model_name(step[:model], label)
95
+ attempts = step.fetch(:attempts, 1)
96
+ unless attempts.is_a?(Integer) && attempts.positive?
97
+ raise ConfigurationError, "#{label} (#{model}): attempts: must be a positive Integer, got #{attempts.inspect}"
98
+ end
99
+ if step.key?(:order) && !step[:order].is_a?(Integer)
100
+ raise ConfigurationError, "#{label} (#{model}): order: must be an Integer, got #{step[:order].inspect}"
101
+ end
102
+
103
+ params = step[:params] && Params.normalize(step[:params], "#{label} (#{model}) params")
104
+ normalized = { model:, provider: step[:provider], params:, attempts: }
105
+ step.key?(:order) ? normalized.merge(order: step[:order]) : normalized
106
+ end
107
+
108
+ def model_name(model, label)
109
+ name = model.to_s
110
+ raise ConfigurationError, "#{label}: model names can't be blank" if name.strip.empty?
111
+
112
+ name
113
+ end
114
+ end
115
+ end
@@ -9,8 +9,9 @@ module Squishling
9
9
  # overriding RubyLLM's defaults, so these would silently replace the model, the conversation,
10
10
  # or the strict output format.
11
11
  RESERVED_KEYS = %i[
12
- model messages input instructions contents system system_instruction stream stream_options store include
13
- response_format text output_config tools tool_choice schema
12
+ model messages input inputs instructions contents system system_instruction systemInstruction cachedContent
13
+ stream stream_options store include response_format text output_config outputConfig tools tool_choice
14
+ toolConfig schema
14
15
  ].freeze
15
16
 
16
17
  module_function
@@ -4,9 +4,9 @@ module Squishling
4
4
  # Decides, per call, whether a squished method runs its Ruby implementation or the LLM.
5
5
  module Router
6
6
  # One squished call in progress. `phase` is :routing (deciding, e.g. running squish_when), :ruby (running
7
- # the implementation), or :llm (on the LLM path, including any fallback); `escalated` records that squish!
7
+ # the implementation), or :llm (on the LLM path, including any fallback); `handed_off` records that squish!
8
8
  # already sent it to the LLM.
9
- Frame = Struct.new(:receiver, :definition, :wrapper, :inputs, :phase, :escalated)
9
+ Frame = Struct.new(:receiver, :definition, :wrapper, :inputs, :phase, :handed_off)
10
10
 
11
11
  FRAMES_KEY = :__squishling_frames__
12
12
 
@@ -27,7 +27,7 @@ module Squishling
27
27
  value = impl.call
28
28
  rescue NotImplementedError
29
29
  # After squish!, the error came from the LLM path (a fallback, a proc), not a missing implementation.
30
- raise if frame.escalated
30
+ raise if frame.handed_off
31
31
 
32
32
  return route_to_llm(frame, definition, "not implemented")
33
33
  end
@@ -43,7 +43,7 @@ module Squishling
43
43
  end
44
44
 
45
45
  # squish!: hand the call in progress on this receiver to the LLM, with this call's overrides.
46
- def escalate(receiver, overrides)
46
+ def hand_off(receiver, overrides)
47
47
  frame = current_frame(receiver)
48
48
  raise Error, "#{receiver.class}#squish! called outside a squished method" unless frame
49
49
  unless frame.phase == :ruby
@@ -51,7 +51,7 @@ module Squishling
51
51
  "not from squish_when or while already on the LLM path (e.g. from squish_fallback)"
52
52
  end
53
53
 
54
- frame.escalated = true
54
+ frame.handed_off = true
55
55
  route_to_llm(frame, frame.definition.for_call(**overrides), "squish!")
56
56
  end
57
57