Repository navigation
v0.2.0
🚀 What's New
Squishling v0.2.0 adds model escalation: when a model returns invalid output or the provider fails, the call moves on to the next model instead of just retrying the same one. It also adds Ruby output checks (squish_validate) and lets Schematist's conditional rules (given, dependent) work with providers' strict mode.
gem "squishling", "~> 0.2"Requires Ruby 3.3+ and RubyLLM 2.x.
⚠️ Upgrading from 0.1.0
config.max_retries is removed. Reading or setting it raises ConfigurationError with a migration hint. The escalation now decides how many attempts run, and a plain model: makes one attempt.
To keep 0.1.0's behavior (two attempts on one model):
Squishling.configure do |config|
config.default_escalation = [{ model: "your-model", attempts: 2 }]
end🪜 Model Escalation
Declare an escalation: instead of a model:: an ordered list of steps, each a model name or a Hash with model:, attempts:, an optional explicit order:, provider: and params:. Steps can cross providers.
class TicketTriager
include Squishling
squishling escalation: [
{ model: "qwen3:8b", provider: :ollama, attempts: 2 }, # local and free, tried twice
{ model: "claude-haiku-4-5", provider: :bedrock }, # small hosted model
{ model: "claude-opus-5-5", provider: :anthropic } # last resort
]
# instructions, output_schema, ...
end- Invalid output or an
LLMErrormoves on to the next attempt.ConfigurationErrornever does. - Attempts on the same step continue one conversation. A new step starts a fresh chat told about the rejected output and why it was rejected.
- Works per method, per class, in config (
default_escalation) and persquish!call. The first level that declares a model or escalation wins; levels are never merged. InvalidOutputError#modelslists the model tried on each attempt.
✅ Output Checks
squish_validate (or validate: on squish) runs Ruby checks on LLM output that already matches the schema, for rules a schema can't express such as cross-field arithmetic:
squish_validate do |result, **|
"total must equal the sum of line_items" unless result.total == result.line_items.sum(&:amount)
endReturn nil or true to accept. Return false, a String, an Array of Strings or a dry-validation style result to reject, and trigger the next attempt. It doesn't run on deterministic or fallback returns.
Schematist's conditional rules (given, dependent) are now kept out of the schema sent to the provider, whose strict mode doesn't support them, and are still enforced locally on every result.
🔒 Security and Fixes
- Params can't override what Squishling owns. The camelCase and plural request keys used by Google Gemini, Amazon Bedrock Converse and Mistral Conversations (
systemInstruction,cachedContent,toolConfig,outputConfig,inputs) are now rejected withConfigurationError, like the other reserved keys. - Argument names. A method with an unnamed positional parameter (a destructuring parameter such as
def call((a, b), second)) sent every later argument to the LLM under the wrong name. Each argument now keeps its own name; unnamed ones are sent asarg0,arg1, and so on. squish!'s Ruby-to-LLM handoff is now called "handing off", so "escalation" only ever means the model list.squish!also acceptsescalation:for one call.
⚠️ Worth Knowing
- A rejected output is forwarded to the next step's provider, which may differ from the one that produced it.
- Nested provider config such as Gemini's
generationConfigcan still carry keys that change the output format. Local validation always rejects bad output, and we're tracking hardening in #8.