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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +34 -7
- data/docs/configuration.md +83 -26
- data/docs/failures.md +51 -14
- data/docs/routing.md +11 -5
- data/docs/schemas.md +29 -0
- data/lib/squishling/class_methods.rb +29 -10
- data/lib/squishling/configuration.rb +40 -10
- data/lib/squishling/definition.rb +36 -22
- data/lib/squishling/errors.rb +7 -4
- data/lib/squishling/invoker.rb +133 -39
- data/lib/squishling/model_path.rb +115 -0
- data/lib/squishling/params.rb +3 -2
- data/lib/squishling/router.rb +5 -5
- data/lib/squishling/schema.rb +45 -3
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +6 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad18b43555b041d5c0ef6eabda578fc6a2111ecfbf962b2003a7b502584f0c91
|
|
4
|
+
data.tar.gz: f4ce9bb54bd2c57abd73c5a2f0e50472c338e299f25fe7ed0dd2edb2f4854f06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[
|
|
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
|
-
- **
|
|
112
|
-
|
|
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,
|
|
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):
|
|
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
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
## Models and escalation
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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`,
|
|
111
|
-
|
|
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
|
|
128
|
-
settings the same way they layer over each other. See [
|
|
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
|
|
8
|
-
| Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never
|
|
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
|
|
10
|
-
| Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) |
|
|
11
|
-
| Malformed or truncated JSON |
|
|
12
|
-
| JSON that doesn't match the schema (wrong types, missing or extra keys, root not an object) |
|
|
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.
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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 [
|
|
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 [
|
|
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
|
-
##
|
|
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.
|
|
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,
|
|
17
|
-
output_schema: nil)
|
|
18
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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: "...",
|
|
105
|
-
# fallback: ->(error, **) { { priority: "medium" } }, append_instructions: [...]
|
|
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,
|
|
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
|