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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76e867f7a93f81c06fb80016fcbeb1e1db39a064f1b8bb1e7cd98194bbf6ef6e
4
- data.tar.gz: 2c2ea6a4994f4fb764337c9f194c26eb264cea7a7ecae3e881fa3a4d75303e83
3
+ metadata.gz: ad18b43555b041d5c0ef6eabda578fc6a2111ecfbf962b2003a7b502584f0c91
4
+ data.tar.gz: f4ce9bb54bd2c57abd73c5a2f0e50472c338e299f25fe7ed0dd2edb2f4854f06
5
5
  SHA512:
6
- metadata.gz: ac0484e5a496beb3c3e27a51d13765c8b62c243d2253eacd12c1b532017e141780f723df5164bcaa7548e37b28f27fa901ff493fb343a34426ffafe120d96bde
7
- data.tar.gz: 483a5344615d1bc0b1cbb9f3608e4f48df8891eb05dfcb7ad9ac31f22fb3574392f3621d5227037f0a28b48e5389f6e375ef336181ccfd9b3c8836969856cae6
6
+ metadata.gz: 726d27bcc6067039d3bba294a923c302f49fc756ce0882541461525d8421be48d5cbb9c02e1d201eb1c062c9519353f62b66f9cf43795fc46a15c9834d7e24fe
7
+ data.tar.gz: 9e4093ad080e9742b38e6bfe3d59f2b5be21f0bf3ddfd5a8288b552bbe9d0abdffe3d47f6fea8d5d0c54afdb0172c69769451249e65f69cfc2665b96682b1b52
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-09
11
+
12
+ ### Added
13
+
14
+ - Model escalation: `escalation:` (and `default_escalation` in `Squishling.configure`) declares an ordered list of
15
+ steps, each a model name or a Hash with `model:`, `attempts:`, an optional explicit `order:`, `provider:`, and
16
+ `params:`. Invalid output or an `LLMError` moves on to the next attempt. Attempts on the same step continue one
17
+ conversation; a new step starts a fresh chat that is told about the rejected output and its errors.
18
+ `ConfigurationError` never escalates. Works per method, per class, in config, and per `squish!` call; the first
19
+ level that declares a model or escalation wins and levels are never merged. `InvalidOutputError#models` lists the
20
+ model tried on each attempt. (#6)
21
+ - `squish_validate` (and `validate:` on `squish`): Ruby checks on schema-valid LLM output. Return `nil` or `true` to
22
+ accept, or `false`, a String, an Array of Strings, or a dry-validation style result to reject the output and
23
+ trigger the next attempt. Deterministic and fallback returns are not run through it. (#6)
24
+ - Conditional schema rules: Schematist's `given` and `dependent` (JSON Schema `if`/`then`/`else`,
25
+ `dependentRequired`, and `dependentSchemas`) are kept out of the schema sent to the provider, whose strict mode
26
+ doesn't support them, and are still enforced locally on every result. (#6)
27
+
28
+ ### Changed
29
+
30
+ - **Breaking:** `config.max_retries` is removed; reading or setting it raises `ConfigurationError` with a migration
31
+ hint. The escalation now decides how many attempts run, and a plain `model:` makes a single attempt. To keep the
32
+ old behavior (two attempts on one model), set
33
+ `config.default_escalation = [{ model: "your-model", attempts: 2 }]`. (#6)
34
+ - `squish!`'s Ruby-to-LLM handoff is now described as "handing off", so "escalation" only means the model list.
35
+ `squish!` also accepts `escalation:` (instead of `model:`) for one call. (#6)
36
+
37
+ ### Fixed
38
+
39
+ - A method with an unnamed positional parameter (a destructuring parameter such as `def call((a, b), second)`) sent
40
+ every later argument to the LLM under the wrong name. Each argument now keeps its own name, and unnamed ones are
41
+ sent as `arg0`, `arg1`, and so on.
42
+
43
+ ### Security
44
+
45
+ - Params can no longer override the system prompt, tool config, or structured-output format through the camelCase
46
+ and plural request keys used by Google Gemini, Amazon Bedrock Converse, and Mistral Conversations
47
+ (`systemInstruction`, `cachedContent`, `toolConfig`, `outputConfig`, `inputs`); these now raise
48
+ `ConfigurationError` like the other reserved keys.
49
+
10
50
  ## [0.1.0] - 2026-10-08
11
51
 
12
52
  Initial release.
data/README.md CHANGED
@@ -71,7 +71,7 @@ InvoiceParser.call(vendor: "acme", document: scanned_text).squished? # => true
71
71
 
72
72
  Both calls return the same result class. If the LLM can't deliver either, `squish_fallback` decides what to
73
73
  return, or the error is raised with the original `ParseError` as its cause. See
74
- [Escalating from Ruby](docs/routing.md#escalating-from-ruby-with-squish).
74
+ [Handing off to the LLM](docs/routing.md#handing-off-to-the-llm-with-squish).
75
75
 
76
76
  ## Installation
77
77
 
@@ -80,11 +80,12 @@ gem "squishling"
80
80
  ```
81
81
 
82
82
  Requires Ruby 3.3+ and RubyLLM 2.x. Configure your provider API keys in RubyLLM as usual, then optionally set a
83
- universal model:
83
+ universal model, or an escalation of models to try in order:
84
84
 
85
85
  ```ruby
86
86
  Squishling.configure do |config|
87
87
  config.default_model = "claude-sonnet-5-5" # falls back to RubyLLM's default when nil
88
+ # or: config.default_escalation = [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
88
89
  end
89
90
  ```
90
91
 
@@ -108,18 +109,44 @@ Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM
108
109
  - **Any RubyLLM provider and model**: OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more.
109
110
  Set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
110
111
  reasoning effort, top_p, …).
111
- - **Defined failure behavior**: invalid output is re-asked with the validation errors, provider errors become
112
- `Squishling::LLMError`, and `squish_fallback` decides what to return when the LLM can't deliver.
112
+ - **Model escalation**: declare an `escalation:` instead of a `model:`, with per-step `attempts:`, `order:`, provider,
113
+ and params. Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
114
+ cross providers, e.g. a local Qwen model on Ollama, then Anthropic Claude Haiku on AWS Bedrock, then Claude Opus:
115
+
116
+ ```ruby
117
+ RubyLLM.configure do |config|
118
+ config.ollama_api_base = "http://localhost:11434/v1"
119
+ config.bedrock_api_key = ENV["AWS_ACCESS_KEY_ID"]
120
+ config.bedrock_secret_key = ENV["AWS_SECRET_ACCESS_KEY"]
121
+ config.bedrock_region = "us-east-1"
122
+ config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
123
+ end
124
+
125
+ class TicketTriager
126
+ include Squishling
127
+
128
+ squishling escalation: [
129
+ { model: "qwen3:8b", provider: :ollama, attempts: 2 }, # local and free, tried twice
130
+ { model: "claude-haiku-4-5", provider: :bedrock }, # small hosted model
131
+ { model: "claude-opus-5-5", provider: :anthropic } # last resort
132
+ ]
133
+ # instructions, output_schema, ...
134
+ end
135
+ ```
136
+ - **Output contracts**: beyond the strict schema, conditional rules (`given`) and Ruby checks (`squish_validate`)
137
+ reject bad output and trigger the next attempt.
138
+ - **Defined failure behavior**: provider errors become `Squishling::LLMError`, and `squish_fallback` lets you decide
139
+ what to return when every attempt fails.
113
140
  - **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
114
141
  pass to `squish!`, and the source you choose to append are sent to the provider.
115
142
 
116
143
  ## Documentation
117
144
 
118
- - [Configuration](docs/configuration.md): options, model and provider resolution, generation params, inheritance
145
+ - [Configuration](docs/configuration.md): options, models and escalation, providers, generation params, inheritance
119
146
  - [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `append_instructions`, hardening a path,
120
147
  what the LLM sees
121
- - [Output schemas](docs/schemas.md): schema forms, strict mode, typed results, optional vs. empty
122
- - [Failure handling](docs/failures.md): retries, error classes, fallbacks
148
+ - [Output schemas](docs/schemas.md): schema forms, strict mode, typed results, optional vs. empty, contracts
149
+ - [Failure handling](docs/failures.md): escalation, `squish_validate`, error classes, fallbacks
123
150
  - [Live examples](examples/README.md): end-to-end tests against Anthropic Claude Haiku and OpenAI GPT-6 Luna
124
151
 
125
152
  ## Development
@@ -14,46 +14,102 @@ Then configure Squishling:
14
14
 
15
15
  ```ruby
16
16
  Squishling.configure do |config|
17
- config.default_model = "claude-sonnet-5-5"
17
+ config.default_model = "claude-sonnet-5-5" # or default_escalation, below
18
18
  config.default_provider = nil
19
19
  config.default_params = { temperature: 0 }
20
- config.max_retries = 1
21
20
  config.logger = Rails.logger
22
21
  end
23
22
  ```
24
23
 
25
24
  | Option | Default | Description |
26
25
  |---|---|---|
27
- | `default_model` | `nil` | The universal model for every squishling class that doesn't declare one. `nil` uses RubyLLM's `default_model`. |
28
- | `default_provider` | `nil` | The provider for `default_model`. Only needed for models missing from RubyLLM's registry (see below). |
29
- | `default_params` | `{}` | Generation params for every call (temperature, reasoning effort, top_p, …), overridable per class and per method. See [Generation params](#generation-params). |
30
- | `max_retries` | `1` | How many times to re-ask the LLM after schema-invalid output before raising `InvalidOutputError`. `0` means a single attempt. See [Failure handling](failures.md). |
31
- | `logger` | `nil` | Any `Logger`. Debug lines when a call routes to the LLM, warnings when a fallback is used. |
26
+ | `default_model` | `nil` | The universal model (a single attempt) for every squishling class that doesn't declare one. `nil` uses RubyLLM's `default_model`. |
27
+ | `default_escalation` | `nil` | The universal [escalation](#models-and-escalation): models tried in order. Assigning it clears `default_model`, and vice versa. |
28
+ | `default_provider` | `nil` | The provider for `default_model`/`default_escalation` steps that don't name one. Only needed for models missing from RubyLLM's registry (see below). |
29
+ | `default_params` | `{}` | Generation params for every call (temperature, reasoning effort, top_p, …), overridable per class, per method, and per escalation step. See [Generation params](#generation-params). |
30
+ | `logger` | `nil` | Any `Logger`. Debug lines when a call routes to the LLM, warnings on each escalation and when a fallback is used. |
32
31
 
33
32
  Transport-level retries (rate limits, 5xx, timeouts) are configured on RubyLLM itself
34
33
  (`RubyLLM.config.max_retries`, `request_timeout`).
35
34
 
36
- ## Model resolution
35
+ > **`max_retries` was removed.** The escalation now decides how many attempts run, and setting
36
+ > `config.max_retries` raises `ConfigurationError`. The old default (`max_retries = 1`, two attempts on one model) is
37
+ > `default_escalation = [{ model: "your-model", attempts: 2 }]`. A plain `model:` now makes one attempt.
37
38
 
38
- The model for a squished call comes from the first level that declares one:
39
+ ## Models and escalation
39
40
 
40
- 1. per call: `squish!(model: "...")` (see [Per-call overrides](#per-call-overrides))
41
- 2. per method: `squish :name, model: "..."`
42
- 3. per class: `squishling model: "..."` (inherited by subclasses)
43
- 4. universal: `Squishling.config.default_model`
44
- 5. RubyLLM's `default_model`
41
+ Declare **either** a `model:`, one model with a single attempt, **or** an `escalation:`, the models to try in
42
+ order. When an attempt fails (invalid output, a [`squish_validate`](failures.md#output-checks-squish_validate)
43
+ rejection, or a provider failure), the call moves on to the next attempt. It stops at the first valid output, or
44
+ raises once the last attempt fails.
45
+
46
+ ```ruby
47
+ Squishling.configure do |config|
48
+ config.default_escalation = [
49
+ { model: "claude-haiku-4-5", attempts: 2 }, # attempts 1-2: same conversation, told what was wrong
50
+ "claude-sonnet-5-5", # attempt 3: fresh chat, shown Haiku's last output and errors
51
+ "claude-opus-5-5" # attempt 4
52
+ ]
53
+ end
54
+ ```
55
+
56
+ Each step is a model name, or a Hash with:
57
+
58
+ | Key | Default | Description |
59
+ |---|---|---|
60
+ | `model:` | required | The model id. |
61
+ | `attempts:` | `1` | How many attempts this step gets before escalating to the next one. |
62
+ | `order:` | position | An Integer; steps run lowest first. Give every step an `order:` or none (duplicates are rejected). Gaps are fine. |
63
+ | `provider:` | the level's `provider:` | See [Providers](#providers-and-newly-released-models). |
64
+ | `params:` | `{}` | [Generation params](#generation-params) for this step, merged over the class and method params key by key. |
65
+
66
+ Without `order:`, steps run in the order written. With it, the order is explicit and doesn't depend on position:
67
+
68
+ ```ruby
69
+ squishling escalation: [
70
+ { model: "claude-opus-5-5", order: 20, params: { thinking: { effort: :high } } },
71
+ { model: "claude-haiku-4-5", order: 0, attempts: 2 },
72
+ { model: "claude-sonnet-5-5", order: 10 }
73
+ ]
74
+ ```
75
+
76
+ `params:` on a step let one step switch to a reasoning model that rejects sampling params:
77
+
78
+ ```ruby
79
+ squishling params: { temperature: 0 },
80
+ escalation: ["gpt-5-mini", { model: "gpt-6-luna", provider: :openai,
81
+ params: { temperature: nil, thinking: { effort: :high } } }]
82
+ ```
83
+
84
+ ### Which model or escalation applies
85
+
86
+ The first level that declares a `model:` or an `escalation:` supplies the whole thing (levels aren't merged):
87
+
88
+ 1. per call: `squish!(model: ...)` or `squish!(escalation: ...)` (see [Per-call overrides](#per-call-overrides))
89
+ 2. per method: `squish :name, model: ...` or `escalation: ...`
90
+ 3. per class: `squishling model: ...` or `escalation: ...` (inherited by subclasses)
91
+ 4. universal: `Squishling.config.default_model` or `default_escalation`
92
+ 5. RubyLLM's `default_model` (a single attempt)
45
93
 
46
94
  ```ruby
47
95
  class InvoiceParser
48
96
  include Squishling
49
- squishling model: "claude-sonnet-5-5" # class default
97
+ squishling escalation: %w[claude-sonnet-5-5 claude-opus-5-5] # class escalation
50
98
 
51
- squish :classify, model: "claude-haiku-4-5" do # cheaper model for one method
99
+ squish :classify, model: "claude-haiku-4-5" do # one cheap attempt for this method
52
100
  string :category
53
101
  end
54
102
  end
55
103
  ```
56
104
 
105
+ Passing both `model:` and `escalation:` in one declaration raises `ConfigurationError`, and so does a list passed as
106
+ `model:`. Across separate declarations (a reopened class, or two config assignments), the latest one wins.
107
+
108
+ Consecutive attempts of the same step (same model, provider, and params, e.g. `attempts: 2`) continue one
109
+ conversation. Moving to a different step starts a fresh chat with the original input plus the previous output and why
110
+ it was rejected, so no provider-specific history crosses providers. Configuration errors (bad credentials, an unknown model, a request the provider rejects) never move to the
111
+ next attempt. See [Failure handling](failures.md).
112
+
57
113
  ## Providers and newly released models
58
114
 
59
115
  RubyLLM looks models up in its bundled registry. To use a model that isn't there yet, such as a newly released
@@ -65,14 +121,14 @@ squish :triage, model: "claude-haiku-4-5", provider: :anthropic
65
121
  Squishling.configure { |c| c.default_model = "gpt-6-luna"; c.default_provider = :openai }
66
122
  ```
67
123
 
68
- A provider is paired with the model declared at the same level, so a per-method model never inherits a
69
- class-level provider meant for a different model. For models that are in the registry, a provider is optional
124
+ A provider is paired with the model or escalation declared at the same level, and applies to that escalation's steps
125
+ that don't name their own. A per-method model never inherits a class-level provider meant for a different model. For models that are in the registry, a provider is optional
70
126
  and the normal registry lookup is kept.
71
127
 
72
128
  ## Generation params
73
129
 
74
- `params` holds generation settings. Set them at any of three levels; each level overrides the one above it
75
- **key by key**:
130
+ `params` holds generation settings. Set them at any of three levels, plus per step in an
131
+ [escalation](#models-and-escalation); each level overrides the one above it **key by key**:
76
132
 
77
133
  ```ruby
78
134
  Squishling.configure { |c| c.default_params = { temperature: 0 } } # every call
@@ -107,15 +163,16 @@ end
107
163
  ```
108
164
 
109
165
  - Keys that Squishling or RubyLLM control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
110
- `response_format`, `text`, `output_config`, `tools`, `tool_choice`, `schema`, …) raise `ConfigurationError`,
111
- because they would override the model, the conversation, or the strict output format.
166
+ `response_format`, `text`, `output_config`, `tools`, `tool_choice`, `schema`, and the camelCase and plural
167
+ spellings other providers use, such as `systemInstruction`, `toolConfig`, `outputConfig`, `inputs`, …) raise
168
+ `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
112
169
  - If a provider rejects a param, the call raises `Squishling::ConfigurationError` naming the params. It isn't
113
170
  retried or sent to `squish_fallback`. See [Failure handling](failures.md).
114
171
 
115
172
  ## Inheritance
116
173
 
117
- Subclasses inherit the model, provider, generation params (merged key by key), instructions, output schema,
118
- `squish_when` predicate, `squish_context` names, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
174
+ Subclasses inherit the model or escalation, provider, generation params (merged key by key), instructions, output schema,
175
+ `squish_when` predicate, `squish_context` names, `squish_validate`, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
119
176
  overridden methods, are routed the same way.
120
177
 
121
178
  `append_instructions` sections are added to, not replaced: a subclass's sections follow its parent's, and
@@ -124,5 +181,5 @@ overridden methods, are routed the same way.
124
181
  ## Per-call overrides
125
182
 
126
183
  Inside a squished method, `squish!` sends the call to the LLM with its own `instructions:`,
127
- `append_instructions:`, `context:`, `model:`/`provider:`, and `params:`. Each layers over the method and class
128
- settings the same way they layer over each other. See [Escalating from Ruby](routing.md#escalating-from-ruby-with-squish).
184
+ `append_instructions:`, `context:`, `model:` or `escalation:` (with `provider:`), and `params:`. Each layers over the method and class
185
+ settings the same way they layer over each other. See [Handing off to the LLM](routing.md#handing-off-to-the-llm-with-squish).
data/docs/failures.md CHANGED
@@ -2,34 +2,71 @@
2
2
 
3
3
  ## When the LLM fails
4
4
 
5
+ Every squished call works through its [escalation](configuration.md#models-and-escalation), one attempt at a time.
6
+ A failed attempt moves on to the next one; once the last fails, the error is raised (or handed to the
7
+ [fallback](#fallbacks)). A plain `model:` makes a single attempt.
8
+
5
9
  | Failure | What Squishling does |
6
10
  |---|---|
7
- | Rate limit, 5xx, overload, timeout, connection error | RubyLLM retries at the HTTP level (`RubyLLM.config.max_retries`, default 3). If it still fails, raises `Squishling::LLMError`; the original exception is its `cause`. |
8
- | Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never retried, never sent to the fallback. |
9
- | Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `ConfigurationError` with the provider's message and the params in use. Never retried, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
10
- | Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) | Re-asks, then raises `Squishling::InvalidOutputError` |
11
- | Malformed or truncated JSON | Re-asks, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
12
- | JSON that doesn't match the schema (wrong types, missing or extra keys, root not an object) | Re-asks with the validation errors, then raises `InvalidOutputError` |
11
+ | Rate limit, 5xx, overload, timeout, connection error | RubyLLM retries at the HTTP level first (`RubyLLM.config.max_retries`, default 3). If it still fails, moves to the next attempt in a fresh chat; on the last attempt, raises `Squishling::LLMError` with the original exception as its `cause`. |
12
+ | Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never escalated, never sent to the fallback. |
13
+ | Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `ConfigurationError` with the provider's message and the params in use. Never escalated, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
14
+ | Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) | Moves to the next attempt, then raises `Squishling::InvalidOutputError` |
15
+ | Malformed or truncated JSON | Moves to the next attempt, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
16
+ | JSON that doesn't match the schema (wrong types, missing or extra keys, `null` in a non-`optional` field, a broken [conditional rule](schemas.md#contracts-beyond-the-schema), root not an object) | Moves to the next attempt with the validation errors, then raises `InvalidOutputError` |
17
+ | Schema-valid output that [`squish_validate`](#output-checks-squish_validate) rejects | Moves to the next attempt with your messages, then raises `InvalidOutputError` |
13
18
 
14
19
  Squishling validates output itself with [json_schemer](https://github.com/davishmcclurg/json_schemer), because
15
- RubyLLM doesn't, and some providers don't enforce strict mode. Re-asks happen in the same conversation, so the model
16
- sees what it got wrong. `Squishling.config.max_retries` (default 1) sets how many follow-ups are allowed.
17
- `InvalidOutputError` exposes `errors`, `raw` (the last response), and `attempts`.
20
+ RubyLLM doesn't, and some providers don't enforce strict mode. When the next attempt is on the same step (same
21
+ model, provider, and params), the re-ask happens in the same conversation, so the model sees what it got wrong. A
22
+ different step gets a fresh chat with the original input plus the rejected output and its errors, so that output is
23
+ sent to the next step's provider, which may not be the one that produced it. `InvalidOutputError` exposes `errors`,
24
+ `raw` (the last response), `attempts`, and `models` (the model tried on each attempt). With a `logger` configured,
25
+ every escalation is logged as a warning.
26
+
27
+ ## Output checks (`squish_validate`)
28
+
29
+ The schema covers shape and types. For rules it can't express, such as cross-field arithmetic or a lookup against
30
+ your data, add a Ruby check. It runs only on LLM output that already matches the schema, receives the typed result
31
+ plus the method's inputs as keywords, and runs against the instance:
32
+
33
+ ```ruby
34
+ class InvoiceParser
35
+ include Squishling
36
+ # ...
37
+ squish_validate do |result, **|
38
+ errors = []
39
+ errors << "total must equal the sum of line_items" unless result.total == result.line_items.sum(&:amount)
40
+ errors << "unknown currency" unless Currency.supported?(result.currency)
41
+ errors
42
+ end
43
+ end
44
+ ```
45
+
46
+ - Return `nil`, `true`, `""`, or `[]` to accept the output.
47
+ - Return a message or an array of messages to reject it. The messages are sent to the next attempt and end up in
48
+ `InvalidOutputError#errors` if every attempt fails. `false` rejects with a generic message.
49
+ - A [dry-validation](https://dry-rb.org/gems/dry-validation/) result works too, if your app already uses it:
50
+ `squish_validate { |result, **| InvoiceContract.new.call(result.to_h) }`. Squishling doesn't depend on dry-rb.
51
+ - Per method: `squish :triage, validate: ->(result, **inputs) { ... }`. Subclasses inherit the class-level check.
52
+ - It doesn't run on deterministic or fallback returns: those are your own code.
53
+ - Exceptions raised inside it propagate unwrapped, like any of your own code.
18
54
 
19
55
  ## Errors
20
56
 
21
57
  | Error | Raised when |
22
58
  |---|---|
23
59
  | `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
24
- | `Squishling::ConfigurationError` | Missing instructions or schema, a non-strict schema, invalid or reserved params, an invalid `append_instructions` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400) |
25
- | `Squishling::InvalidOutputError` | LLM output still invalid after `max_retries`, or a deterministic/fallback return that doesn't match the schema |
26
- | `Squishling::LLMError` | The provider call failed after RubyLLM's own retries, including context-length errors (`cause` holds the original) |
60
+ | `Squishling::ConfigurationError` | Missing instructions or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_instructions` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400) |
61
+ | `Squishling::InvalidOutputError` | LLM output still invalid (schema or `squish_validate`) after every attempt in the escalation, or a deterministic/fallback return that doesn't match the schema |
62
+ | `Squishling::LLMError` | The provider call failed on the last attempt in the escalation, after RubyLLM's own retries, including context-length errors (`cause` holds the original) |
27
63
 
28
64
  Errors raised by your own Ruby code are not wrapped.
29
65
 
30
66
  ## Fallbacks
31
67
 
32
- Use `squish_fallback` to decide what happens when the LLM path fails with `InvalidOutputError` or `LLMError`.
68
+ Use `squish_fallback` to decide what happens when every attempt fails, with `InvalidOutputError` or
69
+ `LLMError`.
33
70
  It receives the error plus the method's inputs as keywords, and runs against the instance:
34
71
 
35
72
  ```ruby
@@ -49,7 +86,7 @@ end
49
86
  - Per method: `squish :triage, fallback: ->(error, **inputs) { ... }`.
50
87
  - Subclasses inherit the class-level fallback.
51
88
  - Calls handed to the LLM with `squish!` use the fallback too. A fallback can't call `squish!` itself; that
52
- raises `Squishling::Error` rather than looping. See [Escalating from Ruby](routing.md#escalating-from-ruby-with-squish).
89
+ raises `Squishling::Error` rather than looping. See [Handing off to the LLM](routing.md#handing-off-to-the-llm-with-squish).
53
90
  - Without a fallback, the error propagates.
54
91
 
55
92
  Routing to Ruby code isn't automatic on failure. The predicate already chose the LLM for this input, so the
data/docs/routing.md CHANGED
@@ -18,7 +18,7 @@ A squished call runs its Ruby implementation unless one of these sends it to the
18
18
  |---|---|---|
19
19
  | `squish_when` (or `when:`) predicate is truthy | class, or per method | before Ruby runs |
20
20
  | The method has no implementation: it isn't defined, or it raises `NotImplementedError` | the method body | when Ruby gives up |
21
- | `squish!` | inside the method, e.g. in a `rescue` | after Ruby has partly run, see [Escalating from Ruby](#escalating-from-ruby-with-squish) |
21
+ | `squish!` | inside the method, e.g. in a `rescue` | after Ruby has partly run, see [Handing off to the LLM](#handing-off-to-the-llm-with-squish) |
22
22
 
23
23
  With no predicate and a working implementation, every call runs Ruby.
24
24
 
@@ -86,13 +86,13 @@ end
86
86
  - `instructions:` (replaces the class's)
87
87
  - `append_instructions:` (added to the class's, see [Appending to the instructions](#appending-to-the-instructions))
88
88
  - `output_schema:` (or a schema block)
89
- - `model:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
89
+ - `model:` or `escalation:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
90
90
  - `when:`, a predicate proc
91
- - `fallback:` (see [Failure handling](failures.md))
91
+ - `validate:` and `fallback:` (see [Failure handling](failures.md))
92
92
 
93
93
  `squish` can come before or after the method's `def`.
94
94
 
95
- ## Escalating from Ruby with `squish!`
95
+ ## Handing off to the LLM with `squish!`
96
96
 
97
97
  Call `squish!` inside a squished method to hand *this call* to the LLM: for example, when the Ruby parser
98
98
  fails on an input it wasn't written for. It sends the call's arguments, as any LLM call would, and returns the
@@ -128,7 +128,7 @@ end
128
128
  | `context:` | A Hash sent under `"context"` with any `squish_context` values (a same-named key wins). Exceptions are sent as `{ "class", "message" }`, never their backtrace. |
129
129
  | `append_instructions:` | Added to the declared sections; `false` (alone or first in an Array) drops them for this call |
130
130
  | `instructions:` | Replaces the instructions |
131
- | `model:`, `provider:`, `params:` | E.g. escalate to a stronger model when Ruby fails. A `provider:` needs a `model:`; `params:` merge key by key over the declared ones. |
131
+ | `model:` or `escalation:`, `provider:`, `params:` | E.g. send this call to a stronger model, or a whole [escalation](configuration.md#models-and-escalation), when Ruby fails. A `provider:` needs a `model:` or `escalation:`; `params:` merge key by key over the declared ones. |
132
132
 
133
133
  - **The output schema can't be overridden.** The call still returns the method's result type.
134
134
  - **Failures** go through the normal LLM path: a declared `squish_fallback` is used, otherwise
@@ -205,6 +205,12 @@ Source is read with Ruby's own parser (Prism) the first time it's needed and cac
205
205
  "context": { "customer_tier": "enterprise", "product": "API" } }
206
206
  ```
207
207
 
208
+ - **Retries and escalation:** another attempt of the same step gets the validation errors in the same conversation. A
209
+ later [escalation](configuration.md#models-and-escalation) step, which may be a different provider (say, local
210
+ Ollama, then hosted Anthropic Claude), gets the same JSON plus the previous model's rejected output and the
211
+ errors, including any messages your [`squish_validate`](failures.md#output-checks-squish_validate) check
212
+ returned. Don't put data in those messages that you wouldn't send as an argument.
213
+
208
214
  `squish_context` names are read from a method of that name if there is one, otherwise from the instance
209
215
  variable. Only context you name is sent. Instance variables are never dumped wholesale, so API clients,
210
216
  database connections, and secrets stay out of the prompt.
data/docs/schemas.md CHANGED
@@ -85,3 +85,32 @@ branch as usual: `vitals` is a `Data` object or `nil`, and a list of objects is
85
85
  JSON Schema, `type: ["array", "null"]` works too. A union with more than one non-null branch is ambiguous, so its
86
86
  values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in your
87
87
  instructions.
88
+
89
+ ## Contracts beyond the schema
90
+
91
+ Every response is validated against the full schema, so some contracts are already enforced:
92
+
93
+ - **A non-`optional` field can't be `null`.** `string :reason` rejects `"reason": null`. Make a field `optional` only
94
+ when "not provided" is a real answer.
95
+ - **Constraints** such as `enum`, `min_length`, `pattern`, `minimum`, and `min_items` are checked locally, even where
96
+ a provider's strict mode ignores them.
97
+
98
+ For rules that depend on another field, use Schematist's conditionals. A field can be nullable in general but
99
+ required when a condition holds:
100
+
101
+ ```ruby
102
+ output_schema do
103
+ string :status, enum: %w[approved rejected]
104
+ optional(:reason) { string } # nil is fine when approved...
105
+ given(status: "rejected") { string :reason, min_length: 1 } # ...but not when rejected
106
+ end
107
+ ```
108
+
109
+ Providers' strict modes don't support conditional keywords (`if`/`then`/`else` from `given`, and `dependentRequired`/
110
+ `dependentSchemas` from `dependent`), so Squishling keeps them out of the schema it sends and enforces them itself.
111
+ Output that breaks a rule is rejected like any other invalid output: the call moves on to the next attempt in its
112
+ [escalation](configuration.md#models-and-escalation) with the errors. The model doesn't see these rules
113
+ up front, so state them in your instructions too.
114
+
115
+ For anything a schema can't express (sums, lookups against your data), use
116
+ [`squish_validate`](failures.md#output-checks-squish_validate).
@@ -7,15 +7,18 @@ module Squishling
7
7
 
8
8
  # Set class-wide options in one call:
9
9
  # squishling model: "claude-sonnet-5-5", instructions: "...", output_schema: MySchema
10
+ # model: is a single attempt; escalation: is a list of models tried in order (see ModelPath):
11
+ # squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
10
12
  # Pass provider: alongside model: for models missing from RubyLLM's registry, e.g.
11
13
  # squishling model: "gpt-6-luna", provider: :openai
12
14
  # params: are generation params merged over the configured defaults (see Configuration#default_params):
13
15
  # squishling params: { temperature: 0.1, top_p: 0.9 }
14
16
  # append_instructions: adds sections after the instructions (see #append_instructions):
15
17
  # squishling append_instructions: ["The Ruby that handles well-formed input:", self]
16
- def squishling(model: nil, provider: nil, params: nil, instructions: nil, append_instructions: nil,
17
- output_schema: nil)
18
- @squishling_model = model if model
18
+ def squishling(model: nil, escalation: nil, provider: nil, params: nil, instructions: nil,
19
+ append_instructions: nil, output_schema: nil)
20
+ path = ModelPath.declare(model, escalation, to_s)
21
+ @squishling_model_path = path if path
19
22
  @squishling_provider = provider if provider
20
23
  @squishling_params = Params.normalize(params, "#{self} params") if params
21
24
  self.instructions(instructions) if instructions
@@ -24,8 +27,9 @@ module Squishling
24
27
  self
25
28
  end
26
29
 
27
- def squishling_model
28
- squishling_lookup(:@squishling_model)
30
+ # The class's (or nearest ancestor's) model or escalation, as normalized steps.
31
+ def squishling_model_path
32
+ squishling_lookup(:@squishling_model_path)
29
33
  end
30
34
 
31
35
  def squishling_provider
@@ -91,6 +95,19 @@ module Squishling
91
95
  squishling_lookup(:@squishling_fallback)
92
96
  end
93
97
 
98
+ # Extra checks on LLM output that already matches the schema, called with the typed result and the
99
+ # method's inputs (as keywords), evaluated against the instance. Return nil (or true, "", []) to accept,
100
+ # or error message(s) to reject the output and move on to the next attempt, with the messages fed back:
101
+ # squish_validate { |result, **| "total must equal the line items" if result.total != result.line_items.sum }
102
+ # A dry-validation style result (responding to success? and errors) is accepted too.
103
+ def squish_validate(&block)
104
+ @squishling_validator = block
105
+ end
106
+
107
+ def squishling_validator
108
+ squishling_lookup(:@squishling_validator)
109
+ end
110
+
94
111
  # Instance state (attributes or instance variables) to send to the LLM alongside the arguments.
95
112
  def squish_context(*names)
96
113
  (@squishling_context_names ||= []).concat(names.map(&:to_sym))
@@ -101,19 +118,21 @@ module Squishling
101
118
  end
102
119
 
103
120
  # Make methods elastic. Each may override the class-level settings:
104
- # squish :triage, instructions: "...", model: "...", provider: :openai, when: ->(**) { true },
105
- # fallback: ->(error, **) { { priority: "medium" } }, append_instructions: [...] do
121
+ # squish :triage, instructions: "...", escalation: %w[claude-haiku-4-5 claude-sonnet-5-5], when: ->(**) { true },
122
+ # fallback: ->(error, **) { { priority: "medium" } }, append_instructions: [...],
123
+ # validate: ->(result, **) { "team is required" if result.team.empty? } do
106
124
  # string :priority
107
125
  # end
108
- def squish(*names, instructions: nil, append_instructions: nil, output_schema: nil, model: nil, provider: nil,
109
- params: nil, when: nil, fallback: nil, &schema_block)
126
+ def squish(*names, instructions: nil, append_instructions: nil, output_schema: nil, model: nil, escalation: nil,
127
+ provider: nil, params: nil, when: nil, fallback: nil, validate: nil, &schema_block)
110
128
  schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
129
+ model = ModelPath.declare(model, escalation, "#{self} squish")
111
130
  params &&= Params.normalize(params, "#{self} squish params")
112
131
  unless append_instructions.nil?
113
132
  append_instructions = Appendices.normalize(append_instructions, "#{self} squish append_instructions")
114
133
  end
115
134
  options = { instructions:, append_instructions:, output_schema: schema, model:, provider:, params:,
116
- predicate: binding.local_variable_get(:when), fallback: }.compact
135
+ predicate: binding.local_variable_get(:when), fallback:, validator: validate }.compact
117
136
 
118
137
  names.map(&:to_sym).each do |name|
119
138
  (@squishling_methods ||= {})[name] = options