Skip to content

Releases: Coolhand-Labs/squishling

v0.3.0

Choose a tag to compare

@mikecarroll mikecarroll released this 10 Oct 13:46
f224d93

🚀 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:", self

2. 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: or escalation: no longer inherits its parent's provider:. Set provider: next to the subclass's model if you relied on that.
  • A provider: with no model beside it (squishling provider: ..., squish ..., provider: ...) now raises ConfigurationError instead of being silently ignored.
  • include Squishling now raises ConfigurationError if the class inherits a method Squishling would override (for example Sinatra::Base.call). The result alias is only added when the class has no result of 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, so squish_fallback stays 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
end

metadata 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: false on 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#message and logger warnings no longer quote the response; unparseable JSON reports only the line and column. The raw output is still on InvalidOutputError#raw. A chat judge's reason is model-written, so it is no longer in DisagreementError#message or the logs either (read it from #reason).
  • Params can't replace the output format through nested containers. generationConfig / generation_config (Google Gemini) and completion_args (Mistral) now reject their format and tool keys, and tool_config is reserved. Settings such as topK still 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 to Infinity), 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: false to 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, so squish_fallback can 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

Choose a tag to compare

@mikecarroll mikecarroll released this 09 Oct 16:26
6af6cc1

🚀 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 LLMError moves on to the next attempt. ConfigurationError never 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 per squish! call. The first level that declares a model or escalation wins; levels are never merged.
  • InvalidOutputError#models lists 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)
end

Return 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 with ConfigurationError, 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 as arg0, 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 accepts escalation: 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 generationConfig can 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

Choose a tag to compare

@mikecarroll mikecarroll released this 08 Oct 18:12
3390345

🚀 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?      # => true

When 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 })
end

append_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_when predicate chooses Ruby or the LLM per call, and a method that is missing or raises NotImplementedError goes to the LLM automatically. squish :name, ... squishes methods other than call.
  • Strict schemas only. Define output with a Schematist DSL block, a Schematist::Schema subclass, or a raw JSON Schema Hash. Results are typed Data objects with squished?, to_h and []. optional expresses "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), InvalidOutputError and LLMError (original exception as cause). squish_fallback decides 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_context values you name, and the squish! context and appended source you choose. Instance variables are never sent wholesale.

⚠️ Worth Knowing

  • Appended source is sent to your provider. Don't append_instructions a class that holds secrets in constants.
  • Provider setup is RubyLLM's. Configure API keys with RubyLLM.configure. Squishling adds default_model, default_provider, default_params, max_retries and logger.

🔗 Resources