Skip to content

v0.2.0

Choose a tag to compare

@mikecarroll mikecarroll released this 09 Oct 16:26
· 25 commits to main since this release
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