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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +34 -7
- data/docs/configuration.md +83 -26
- data/docs/failures.md +51 -14
- data/docs/routing.md +11 -5
- data/docs/schemas.md +29 -0
- data/lib/squishling/class_methods.rb +29 -10
- data/lib/squishling/configuration.rb +40 -10
- data/lib/squishling/definition.rb +36 -22
- data/lib/squishling/errors.rb +7 -4
- data/lib/squishling/invoker.rb +133 -39
- data/lib/squishling/model_path.rb +115 -0
- data/lib/squishling/params.rb +3 -2
- data/lib/squishling/router.rb +5 -5
- data/lib/squishling/schema.rb +45 -3
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +6 -3
- metadata +2 -1
|
@@ -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
|
|
7
|
-
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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
|
|
28
|
-
# the call must still return the method's result type.
|
|
29
|
-
def for_call(instructions: nil, append_instructions: nil, context: nil, model: nil,
|
|
30
|
-
|
|
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:
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
83
|
-
|
|
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
|
-
|
|
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
|
data/lib/squishling/errors.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
22
|
+
super("#{source} output was invalid#{tries}: #{errors.join('; ')}")
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
|
data/lib/squishling/invoker.rb
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
chat
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
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,
|
|
81
|
+
def apply_params(chat, params)
|
|
82
|
+
Params.apply(chat, params)
|
|
56
83
|
rescue ArgumentError => e
|
|
57
|
-
raise ConfigurationError, "#{@definition.label}: invalid params #{
|
|
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
|
|
61
|
-
# still fails is surfaced as an LLMError
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
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,
|
|
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 =
|
|
78
|
-
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
|
|
148
|
-
|
|
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
|
data/lib/squishling/params.rb
CHANGED
|
@@ -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
|
|
13
|
-
response_format text output_config tools tool_choice
|
|
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
|
data/lib/squishling/router.rb
CHANGED
|
@@ -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); `
|
|
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, :
|
|
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.
|
|
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
|
|
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.
|
|
54
|
+
frame.handed_off = true
|
|
55
55
|
route_to_llm(frame, frame.definition.for_call(**overrides), "squish!")
|
|
56
56
|
end
|
|
57
57
|
|