Repository navigation
Releases: Coolhand-Labs/squishling
Release list
v0.3.0
🚀 What's New
Squishling v0.3.0 adds harnesses: instead of only walking an escalation until one model passes, a call can ask two samples and accept the answer only when they agree, with an optional judge to break ties. It also adds an opt-in squawk hook for sending what the model returned to your error tracker, and keeps model output out of error messages and logs everywhere else.
gem "squishling", "~> 0.3"Requires Ruby 3.3+ and RubyLLM 2.1+.
⚠️ Upgrading from 0.2.0
Three changes can break existing code.
1. instructions is now purpose (and append_instructions is now append_to_purpose), including the keywords on squishling, squish and squish!. There are no aliases, so rename them:
# before
instructions "Extract the invoice fields."
append_instructions "The Ruby parser:", self
# after
purpose "Extract the invoice fields."
append_to_purpose "The Ruby parser:", self2. ruby_llm must be ~> 2.1. Run bundle update ruby_llm.
3. Every return value is now validated, not only Hashes. On the Ruby path and in squish_fallback, a nil, String, Array or other non-Hash return from a method with an output_schema used to pass through. It now raises InvalidOutputError. Return a Hash (or result(...)) that matches the schema.
Also worth a look:
- A subclass that declares its own
model:orescalation:no longer inherits its parent'sprovider:. Setprovider:next to the subclass's model if you relied on that. - A
provider:with no model beside it (squishling provider: ...,squish ..., provider: ...) now raisesConfigurationErrorinstead of being silently ignored. include Squishlingnow raisesConfigurationErrorif the class inherits a method Squishling would override (for exampleSinatra::Base.call). Theresultalias is only added when the class has noresultof its own.
🧪 Harnesses
Choose how a call uses its escalation with harness:, on a class, a method, a squish! call, or config.default_harness.
class TicketTriager
include Squishling
squishling escalation: %w[claude-haiku-4-5 claude-sonnet-5-5 claude-opus-5-5],
harness: :judged_ensemble
purpose "Assign a priority and a team."
# output_schema ...
end| Harness | What it does |
|---|---|
:escalation (default) |
Today's behavior: try each attempt until one passes. |
:squishsum |
Ask the first step twice, concurrently. Accept only if the samples agree (exact match, or your own compare:). |
:ensemble |
Ask the first and second steps once each, so two different models check each other. |
:judged_squishsum / :judged_ensemble |
When the samples disagree, a judge (the next step, or a judge: step) picks one or rejects both. A judge can be a chat model or a Jev-style decision model through RubyLLM.judge. |
- Disagreements raise the new
Squishling::DisagreementError < InvalidOutputError, which carries both typed results, sosquish_fallbackstays the only fallback. - Each sample retries within its own step's
attempts:. Only the HTTP requests run on worker threads; parsing, validation and your callbacks stay on the caller's thread. - An ensemble needs at least two distinct steps (adjacent identical steps count as one step's attempts).
See Harnesses.
📡 Observing Every Attempt: squawk
squawk is an opt-in hook that runs after every LLM attempt, accepted or rejected, and is the one sanctioned way for model output to leave the process. It does nothing unless you set it.
Squishling.configure do |config|
config.squawk = lambda do |output:, metadata:, error:|
Tracer.record(output, metadata, error) if error
end
endmetadata carries the label, attempt number, model, provider, params, the JSON that was sent, and token usage. It can also be set per class (squishling squawk: ...) or per method, and a lambda only receives the keywords it declares. The new Measuring token spend guide shows how to use it for cost tracking.
🪜 Escalation Control
forward_rejected: falseon a step starts it from the original input alone, instead of showing it the previous model's rejected output. Handy when the next step is a different provider. The default is unchanged, and forwarded output is now capped at 4,000 characters.
🔒 Security and Fixes
- Model output stays out of errors and logs.
InvalidOutputError#messageand logger warnings no longer quote the response; unparseable JSON reports only the line and column. The raw output is still onInvalidOutputError#raw. A chat judge'sreasonis model-written, so it is no longer inDisagreementError#messageor the logs either (read it from#reason). - Params can't replace the output format through nested containers.
generationConfig/generation_config(Google Gemini) andcompletion_args(Mistral) now reject their format and tool keys, andtool_configis reserved. Settings such astopKstill work. This closes the gap noted in 0.2.0 (#8). - Unrepresentable responses are invalid output. A number outside JSON's range such as
1e400(which parses toInfinity), or a string with invalid UTF-8, was accepted on the LLM path while the same value from Ruby was rejected. It is now rejected on both, and no longer crashes a squishsum comparison or the next escalation step. - Bounded error lists. Only the first 20 validation errors (plus a count of the rest) are fed back, logged and put in the message, so a very large malformed response can't produce a megabyte-sized retry message.
⚠️ Worth Knowing
- A rejected output is still forwarded to the next step's provider by default. Use
forward_rejected: falseto opt out per step. - A provider error that isn't a 400, 401 or 403 (for example a 404 for a wrong model name, or a 422) is treated as an
LLMError, sosquish_fallbackcan hide it. - The live example suite passes on Anthropic Claude Haiku 4.5 and OpenAI GPT-6 Luna; other providers are covered by the offline specs only.
🔗 Resources
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.
🔗 Resources
v0.1.0
🚀 What's New
Squishling v0.1.0 is the first release. It makes Ruby classes elastic: a squished method either runs its Ruby implementation or sends its inputs through an LLM via RubyLLM, and either way returns the same strict-schema-validated, typed result. Callers can't tell the difference.
gem "squishling"Requires Ruby 3.3+ and RubyLLM 2.x.
✨ Instant Integration
Accept a new data source today, before anyone writes a parser. Declare instructions and an output schema, leave the method unimplemented, and every call goes to the LLM:
class PaymentWebhook
include Squishling
instructions "Normalize this payment provider's webhook into our payment event."
output_schema do
string :event, enum: %w[succeeded failed refunded disputed]
integer :amount_cents
string :currency
string :external_id
end
end
event = PaymentWebhook.call(provider: "adyen", payload: request.raw_post)
event.amount_cents # => 4200
event.squished? # => trueWhen one provider carries the volume, write def call for it and add a squish_when predicate. That path runs in Ruby, everything else stays on the LLM, and callers don't change.
🛟 Error Recovery with squish!
Keep the Ruby you have for the inputs it understands, and hand the rest to the LLM instead of failing. squish! sends the current call to the LLM, typically from a rescue, with per-call context:, append_instructions:, instructions:, model:/provider: and params:. A declared squish_fallback still applies.
def call(vendor:, document:)
invoice = VendorFormats.fetch(vendor).parse(document)
result(invoice_number: invoice.number, total: invoice.total)
rescue VendorFormats::ParseError => e
squish!(context: { parse_error: e })
endappend_instructions adds sections to the system prompt at the class, subclass, method, or per-call level. A section can be a String, a Proc, or a class, module, or method rendered as its own Ruby source (read with Prism, so no new runtime dependency).
🧱 The Contract
- Per-input routing. A
squish_whenpredicate chooses Ruby or the LLM per call, and a method that is missing or raisesNotImplementedErrorgoes to the LLM automatically.squish :name, ...squishes methods other thancall. - Strict schemas only. Define output with a Schematist DSL block, a
Schematist::Schemasubclass, or a raw JSON Schema Hash. Results are typedDataobjects withsquished?,to_hand[].optionalexpresses "not provided" while keeping every key required. - Everything is validated. LLM output, Ruby returns and fallback returns are all checked with
json_schemer. Invalid LLM output is re-asked with the validation errors before it raises. - Defined failure behavior. Everything inherits
Squishling::Error:ConfigurationError(never retried or sent to a fallback),InvalidOutputErrorandLLMError(original exception ascause).squish_fallbackdecides what to return when the LLM can't deliver. Exceptions from your own Ruby code are never wrapped. - Any RubyLLM provider and model. OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter and more. Set the model and layered generation params (
temperature,thinking,max_output_tokens, any provider-specific key) universally, per class, per method, or per call. - Opt-in context. The LLM sees only method arguments, the
squish_contextvalues you name, and thesquish!context and appended source you choose. Instance variables are never sent wholesale.
⚠️ Worth Knowing
- Appended source is sent to your provider. Don't
append_instructionsa class that holds secrets in constants. - Provider setup is RubyLLM's. Configure API keys with
RubyLLM.configure. Squishling addsdefault_model,default_provider,default_params,max_retriesandlogger.