squishling 0.3.0 → 0.5.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +63 -15
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +89 -24
  6. data/docs/coolhand.md +66 -0
  7. data/docs/failures.md +129 -20
  8. data/docs/harnesses.md +186 -48
  9. data/docs/measuring-tokens.md +36 -42
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +7 -2
  12. data/docs/schemas.md +33 -6
  13. data/docs/testing.md +183 -0
  14. data/docs/verifying.md +84 -0
  15. data/lib/squishling/adapter/error_body.rb +47 -0
  16. data/lib/squishling/adapter.rb +275 -0
  17. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  18. data/lib/squishling/adapters/openai_options.rb +72 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +72 -8
  24. data/lib/squishling/definition.rb +95 -17
  25. data/lib/squishling/errors.rb +97 -12
  26. data/lib/squishling/harness/base.rb +136 -0
  27. data/lib/squishling/harness/escalation.rb +22 -0
  28. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  29. data/lib/squishling/harness/option_types.rb +94 -0
  30. data/lib/squishling/harness/registry.rb +41 -0
  31. data/lib/squishling/harness/squishsum.rb +22 -0
  32. data/lib/squishling/harness.rb +25 -99
  33. data/lib/squishling/invoker.rb +28 -233
  34. data/lib/squishling/judge.rb +78 -21
  35. data/lib/squishling/llm_client.rb +73 -109
  36. data/lib/squishling/model_path.rb +117 -27
  37. data/lib/squishling/named_registry.rb +64 -0
  38. data/lib/squishling/observer.rb +109 -0
  39. data/lib/squishling/observers/coolhand.rb +86 -0
  40. data/lib/squishling/output_check.rb +13 -9
  41. data/lib/squishling/params.rb +15 -27
  42. data/lib/squishling/railtie.rb +10 -0
  43. data/lib/squishling/result.rb +37 -16
  44. data/lib/squishling/rotation.rb +31 -0
  45. data/lib/squishling/rspec.rb +18 -0
  46. data/lib/squishling/run/messages.rb +79 -0
  47. data/lib/squishling/run/rejections.rb +43 -0
  48. data/lib/squishling/run.rb +320 -0
  49. data/lib/squishling/schema.rb +10 -9
  50. data/lib/squishling/squawk.rb +33 -3
  51. data/lib/squishling/tasks/squishling.rake +18 -0
  52. data/lib/squishling/testing/adapter.rb +194 -0
  53. data/lib/squishling/testing/checks.rb +39 -0
  54. data/lib/squishling/testing/direct.rb +41 -0
  55. data/lib/squishling/testing/errors.rb +18 -0
  56. data/lib/squishling/testing/fixtures.rb +206 -0
  57. data/lib/squishling/testing/helpers.rb +71 -0
  58. data/lib/squishling/testing/minitest.rb +32 -0
  59. data/lib/squishling/testing/observer.rb +31 -0
  60. data/lib/squishling/testing/reply.rb +60 -0
  61. data/lib/squishling/testing/request.rb +75 -0
  62. data/lib/squishling/testing/rspec.rb +30 -0
  63. data/lib/squishling/testing/script.rb +189 -0
  64. data/lib/squishling/testing/state.rb +73 -0
  65. data/lib/squishling/testing/stubs.rb +112 -0
  66. data/lib/squishling/testing.rb +126 -0
  67. data/lib/squishling/verification.rb +39 -0
  68. data/lib/squishling/verifier.rb +179 -0
  69. data/lib/squishling/version.rb +1 -1
  70. data/lib/squishling.rb +89 -4
  71. metadata +53 -21
@@ -1,7 +1,9 @@
1
1
  # Configuration
2
2
 
3
- Squishling sends requests through [RubyLLM](https://rubyllm.com), so configure provider API keys there first
4
- (OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and any other provider RubyLLM supports):
3
+ Squishling sends requests through an [adapter](adapters.md): [RubyLLM](https://rubyllm.com) (OpenAI, Anthropic
4
+ Claude, Google Gemini, AWS Bedrock, OpenRouter, and any other provider RubyLLM supports), any OpenAI-compatible
5
+ endpoint, or your own. With none configured it uses RubyLLM when the `ruby_llm` gem is in your bundle. For RubyLLM,
6
+ configure provider API keys there first (`require "ruby_llm"` first if your app doesn't use `Bundler.require`):
5
7
 
6
8
  ```ruby
7
9
  RubyLLM.configure do |config|
@@ -14,6 +16,7 @@ Then configure Squishling:
14
16
 
15
17
  ```ruby
16
18
  Squishling.configure do |config|
19
+ config.adapter = :ruby_llm # or :openai_compatible, or your own: see Adapters
17
20
  config.default_model = "claude-sonnet-5-5" # or default_escalation, below
18
21
  config.default_provider = nil
19
22
  config.default_params = { temperature: 0 }
@@ -24,16 +27,20 @@ end
24
27
 
25
28
  | Option | Default | Description |
26
29
  |---|---|---|
27
- | `default_model` | `nil` | The universal model (a single attempt) for every squishling class that doesn't declare one. `nil` uses RubyLLM's `default_model`. |
30
+ | `adapter` | `nil` (RubyLLM, when its gem is available) | How requests reach a model: `:ruby_llm`, `:openai_compatible`, a [registered](adapters.md#writing-your-own) name, a Hash with `type:` and that adapter's options, or an adapter instance. See [Adapters](adapters.md). |
31
+ | `default_model` | `nil` | The universal model (a single attempt) for every squishling class that doesn't declare one. `nil` uses the adapter's own default (RubyLLM's `default_model`; the OpenAI-compatible adapter's `default_model:` option). |
28
32
  | `default_escalation` | `nil` | The universal [escalation](#models-and-escalation): models tried in order. Assigning it clears `default_model`, and vice versa. |
29
- | `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). |
33
+ | `default_provider` | `nil` | The provider for `default_model`/`default_escalation` steps that don't name one. What it means depends on the adapter: RubyLLM needs it only for models missing from its registry (see below); the OpenAI-compatible adapter ignores it. |
30
34
  | `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). |
31
- | `squawk` | `nil` | A callable run after every LLM attempt with the raw output, for sending it to an error tracker or tracing tool. Also settable per class and per method. See [Observing every attempt](failures.md#observing-every-attempt-squawk). |
32
- | `default_harness` | `nil` (`:escalation`) | How every squishling class that doesn't declare its own uses its escalation: `:escalation`, `:squishsum`, `:judged_squishsum`, `:ensemble`, `:judged_ensemble`, or a Hash with `type:` and options. See [Harnesses](harnesses.md). |
35
+ | `default_max_total_output_tokens`, `default_max_total_input_tokens` | `nil` (no limit) | The most output (or input) tokens one call may use across all its requests; once reached, the call raises `BudgetExhaustedError` instead of making another attempt. Overridable per class (`squishling max_total_output_tokens: ...`), per method, and per `squish!` call. See [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets). |
36
+ | `squawk` | `nil` | A callable run after every LLM attempt (see `squawk_on`) with the raw output, for sending it to an error tracker or tracing tool. Also settable per class and per method. See [Observing every attempt](failures.md#observing-every-attempt-squawk). |
37
+ | `squawk_on` | `:all` | Which attempts reach the `squawk` hook: `:all`, `:rejections` (errors only), `:retries` (errors plus any accepted attempt after the first), `:final` (the last attempt the escalation allows), or a list of them (any one holding runs the hook). Also settable per class and per method. |
38
+ | `observer` | `nil` | An around-style hook for every provider request: `:coolhand`, any object responding to `around(call)`, or `false` for none. `nil` uses the [Coolhand observer](coolhand.md) when the `coolhand` gem is configured. See [Observers](observers.md). |
39
+ | `default_harness` | `nil` (`:escalation`) | How every squishling class that doesn't declare its own uses its escalation: `:escalation`, `:squishsum`, `:judged_squishsum`, a [registered custom harness](harnesses.md#custom-harnesses), or a Hash with `type:` and options. See [Harnesses](harnesses.md). |
33
40
  | `logger` | `nil` | Any `Logger`. Debug lines when a call routes to the LLM, warnings on each escalation and when a fallback is used. Never includes the raw model response; see [what ends up in errors and logs](failures.md#what-ends-up-in-errors-and-logs). |
34
41
 
35
- Transport-level retries (rate limits, 5xx, timeouts) are configured on RubyLLM itself
36
- (`RubyLLM.config.max_retries`, `request_timeout`).
42
+ Transport-level retries (rate limits, 5xx, timeouts) belong to the adapter: RubyLLM's own
43
+ (`RubyLLM.config.max_retries`, `request_timeout`), or the OpenAI-compatible adapter's `max_retries:` option.
37
44
 
38
45
  > **`max_retries` was removed.** The escalation now decides how many attempts run, and setting
39
46
  > `config.max_retries` raises `ConfigurationError`. The old default (`max_retries = 1`, two attempts on one model) is
@@ -62,21 +69,71 @@ Each step is a model name, or a Hash with:
62
69
  |---|---|---|
63
70
  | `model:` | required | The model id. |
64
71
  | `attempts:` | `1` | How many attempts this step gets before escalating to the next one. |
65
- | `order:` | position | An Integer; steps run lowest first. Give every step an `order:` or none (duplicates are rejected). Gaps are fine. |
72
+ | `priority:` | position | An Integer; steps run lowest first. Give every step a `priority:` or none. Steps with the same priority are one tier (see [Rotating models](#rotating-models-within-a-priority)). Gaps are fine. `order:` is the deprecated name for it. |
73
+ | `rotate:` | `true` | Set `false` on a step to keep its tier in the order written instead of rotating it. |
74
+ | `include_when:` | always | A Proc that leaves this step out of any call where it returns falsy (see [Excluding a model per call](#excluding-a-model-per-call)). |
66
75
  | `provider:` | the level's `provider:` | See [Providers](#providers-and-newly-released-models). |
76
+ | `retry_temperature:` | none | A Numeric `temperature` for this step's retries in the same chat (not its first attempt), to nudge a model that keeps sending the same rejected reply. Don't use it on a step that sets `thinking`, since reasoning models reject `temperature`. |
67
77
  | `forward_rejected:` | `true` | Set `false` to start this step from the original input alone, without the previous step's rejected output and its errors (see below). |
68
78
  | `params:` | `{}` | [Generation params](#generation-params) for this step, merged over the class and method params key by key. |
69
79
 
70
- Without `order:`, steps run in the order written. With it, the order is explicit and doesn't depend on position:
80
+ Without `priority:`, steps run in the order written. With it, the order is explicit and doesn't depend on position:
71
81
 
72
82
  ```ruby
73
83
  squishling escalation: [
74
- { model: "claude-opus-5-5", order: 20, params: { thinking: { effort: :high } } },
75
- { model: "claude-haiku-4-5", order: 0, attempts: 2 },
76
- { model: "claude-sonnet-5-5", order: 10 }
84
+ { model: "claude-opus-5-5", priority: 30, params: { thinking: { effort: :high } } },
85
+ { model: "claude-haiku-4-5", priority: 10, attempts: 2 },
86
+ { model: "gemma-4b", priority: 10 },
87
+ { model: "claude-sonnet-5-5", priority: 20 }
77
88
  ]
78
89
  ```
79
90
 
91
+ **Best practice:** number priorities 10, 20, 30, and so on, so a model can be slotted in between later (a `15`)
92
+ without renumbering the rest. Haiku and Gemma share the lowest priority here, so they take turns going first (see below).
93
+
94
+ ### Rotating models within a priority
95
+
96
+ Give several models the same `priority:` and they share a tier. Each call rotates which of them goes first, so
97
+ every model gets an even share of the real work over many calls, a fair test of how well each handles it:
98
+
99
+ ```ruby
100
+ squishling escalation: [
101
+ { model: "claude-haiku-4-5", priority: 10 },
102
+ { model: "gemma-4b", priority: 10 },
103
+ { model: "claude-sonnet-5-5", priority: 20 }
104
+ ]
105
+ # call 1: haiku, gemma-4b, sonnet call 2: gemma-4b, haiku, sonnet call 3: haiku, gemma-4b, sonnet
106
+ ```
107
+
108
+ - If the model that goes first fails (invalid output or a provider failure, after its own `attempts:`), the other
109
+ models in the tier are tried, in rotated order, before the call escalates to the next priority.
110
+ - Each class and method counts its own calls (`AClass#a`, `AClass#b` and `BClass#a` rotate independently), shared across
111
+ threads, but not across processes: each process (a Puma worker, say) starts its own count at the first model, and it
112
+ restarts with the process. A `squish!` whose own escalation has a rotating tier counts against its method too.
113
+ - A tier rotates by its declared order, so ties keep the order they were written in. A tier with a single model
114
+ never rotates, and neither does an escalation without `priority:`. `rotate: false` on any step of a tier keeps the
115
+ whole tier in the order written, whatever its other steps say.
116
+ - Rotation also moves which stages the [`:squishsum` and `:judged_squishsum`](harnesses.md) harnesses sample first.
117
+
118
+ ### Excluding a model per call
119
+
120
+ `include_when:` takes a Proc, called like a `squish_when` predicate: against the instance, with the method's inputs as
121
+ keywords. A step whose Proc returns falsy is left out of that call's escalation, for example a model without a BAA:
122
+
123
+ ```ruby
124
+ squishling escalation: [
125
+ { model: "gpt-5", include_when: ->(client:, **) { !client.needs_a_baa_model? } },
126
+ "claude-sonnet-5-5"
127
+ ]
128
+ ```
129
+
130
+ - Steps are filtered before rotation, so a tier rotates only between the models still eligible for the call. A step
131
+ that is excluded takes no part in its tier, so an excluded `rotate: false` step no longer pins it.
132
+ - A call whose steps are all excluded raises `ConfigurationError`. It is never retried or passed to `squish_fallback`.
133
+ - It applies wherever an escalation is declared (class, method, `default_escalation`, or `squish!`), but not to a judge
134
+ or harness step, which are single steps.
135
+ - The Proc runs on the calling thread. An exception it raises propagates unwrapped.
136
+
80
137
  `params:` on a step let one step switch to a reasoning model that rejects sampling params:
81
138
 
82
139
  ```ruby
@@ -93,7 +150,7 @@ The first level that declares a `model:` or an `escalation:` supplies the whole
93
150
  2. per method: `squish :name, model: ...` or `escalation: ...`
94
151
  3. per class: `squishling model: ...` or `escalation: ...` (inherited by subclasses)
95
152
  4. universal: `Squishling.config.default_model` or `default_escalation`
96
- 5. RubyLLM's `default_model` (a single attempt)
153
+ 5. the adapter's default model (RubyLLM's `default_model`, or the OpenAI-compatible adapter's `default_model:`; a single attempt)
97
154
 
98
155
  ```ruby
99
156
  class InvoiceParser
@@ -109,16 +166,20 @@ end
109
166
  Passing both `model:` and `escalation:` in one declaration raises `ConfigurationError`, and so does a list passed as
110
167
  `model:`. Across separate declarations (a reopened class, or two config assignments), the latest one wins.
111
168
 
112
- Consecutive attempts of the same step (same model, provider, params, and `forward_rejected:`, e.g. `attempts: 2`)
169
+ Consecutive attempts of the same step (same model, provider, params, `forward_rejected:`, and `retry_temperature:`, e.g. `attempts: 2`)
113
170
  continue one conversation. Moving to a different step starts a fresh chat with the original input plus the previous
114
171
  output and why it was rejected, so no provider-specific history crosses providers. That rejected output is
115
172
  model-generated and is sent to the next step's provider, even when it is a different one, truncated to 4,000
116
173
  characters. If you don't want a step to see it (say, because the next step is a different provider), set
117
174
  `forward_rejected: false` on that step and it starts from the original input only. Configuration errors (bad
118
- credentials, an unknown model, a request the provider rejects) never move to the next attempt. See [Failure handling](failures.md).
175
+ credentials, an unknown model, a request the provider rejects) never move to the next attempt. A retry that returns
176
+ exactly the reply the step was just told was invalid skips the step's remaining attempts and moves to the next step. See [Failure handling](failures.md).
119
177
 
120
178
  ## Providers and newly released models
121
179
 
180
+ This section is about the RubyLLM adapter; the OpenAI-compatible adapter takes model names as given and ignores
181
+ `provider:`.
182
+
122
183
  RubyLLM looks models up in its bundled registry. To use a model that isn't there yet, such as a newly released
123
184
  OpenAI or Anthropic model, name its provider next to it. Squishling then tells RubyLLM to assume the model exists:
124
185
 
@@ -159,14 +220,17 @@ end
159
220
 
160
221
  | Key | Sent as |
161
222
  |---|---|
162
- | `temperature` | RubyLLM's `with_temperature` |
163
- | `max_output_tokens` | RubyLLM's `with_max_output_tokens` (translated to each provider's own field) |
164
- | `thinking` | RubyLLM's `with_thinking`: `true` (the model's default), `false` (off), or options such as `{ effort: :low }`, `{ budget: 1024 }`, `{ display: :omitted }` |
165
- | anything else (`top_p`, `seed`, `service_tier`, Gemini's `generationConfig`, …) | merged into the provider request as-is via `with_provider_options`, in that provider's own field names |
223
+ | `temperature` | RubyLLM's `with_temperature`; `temperature` in the request body for OpenAI-compatible endpoints |
224
+ | `max_output_tokens` | RubyLLM's `with_max_output_tokens` (translated to each provider's own field); `max_completion_tokens` (or `max_tokens`) for OpenAI-compatible endpoints |
225
+ | `thinking` | RubyLLM's `with_thinking`: `true` (the model's default), `false` (off), or options such as `{ effort: :low }`, `{ budget: 1024 }`, `{ display: :omitted }`. OpenAI-compatible endpoints take only `{ effort: }`, as `reasoning_effort`. |
226
+ | anything else (`top_p`, `seed`, `service_tier`, Gemini's `generationConfig`, …) | merged into the provider request as-is (via `with_provider_options` in RubyLLM), in that provider's own field names |
166
227
 
167
228
  - **No params means provider defaults.** Squishling sends nothing unless you set it. For structured
168
229
  extraction on non-reasoning models (e.g. Anthropic Claude Haiku), a low temperature reduces run-to-run
169
230
  variance.
231
+ - **Thinking counts against `max_output_tokens`** on providers that bill it as output: leave room for the thinking
232
+ budget plus the answer. With `thinking: { budget: 8192 }` and `max_output_tokens: 8192`, a reply that uses its full
233
+ budget is cut off before any JSON, which shows as [`InvalidOutputError#truncated?`](failures.md#a-cutoff-or-a-wrong-answer).
170
234
  - **Reasoning models** (e.g. OpenAI GPT-6 Luna) usually reject sampling params like `temperature` and `top_p`;
171
235
  tune `thinking: { effort: }` instead. Anthropic requires `temperature` to be 1 (or unset) when `thinking` is on.
172
236
  - **Unsetting an inherited key:** set it to `nil` to send nothing for that key, so the provider default applies.
@@ -176,8 +240,8 @@ end
176
240
  squish :triage, model: "gpt-6-luna", provider: :openai, params: { temperature: nil, thinking: { effort: :low } }
177
241
  ```
178
242
 
179
- - Keys that Squishling or RubyLLM control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
180
- `response_format`, `text`, `output_config`, `tools`, `tool_choice`, `schema`, and the camelCase and plural
243
+ - Keys that Squishling or the adapter control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
244
+ `response_format`, `text`, `output_config`, `tools`, `tool_choice`, `functions`, `function_call`, `schema`, and the camelCase and plural
181
245
  spellings other providers use, such as `systemInstruction`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
182
246
  raise `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
183
247
  - **Nested containers** such as Gemini's `generationConfig` (and `generation_config` for Gemini Interactions) and
@@ -195,7 +259,7 @@ end
195
259
 
196
260
  ## Inheritance
197
261
 
198
- Subclasses inherit the model or escalation and its provider (as a pair), harness, generation params (merged key by key), purpose, output schema,
262
+ Subclasses inherit the model or escalation and its provider (as a pair), harness, generation params (merged key by key), token budgets (limit by limit), purpose, output schema,
199
263
  `squish_when` predicate, `squish_context` names, `squish_validate`, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
200
264
  overridden methods, are routed the same way.
201
265
 
@@ -205,5 +269,6 @@ overridden methods, are routed the same way.
205
269
  ## Per-call overrides
206
270
 
207
271
  Inside a squished method, `squish!` sends the call to the LLM with its own `purpose:`,
208
- `append_to_purpose:`, `context:`, `model:` or `escalation:` (with `provider:`), `params:`, and `harness:`. Each layers over the method and class
272
+ `append_to_purpose:`, `context:`, `model:` or `escalation:` (with `provider:`), `params:`, `harness:`, and
273
+ `max_total_output_tokens:`/`max_total_input_tokens:`. Each layers over the method and class
209
274
  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/coolhand.md ADDED
@@ -0,0 +1,66 @@
1
+ # Coolhand Labs
2
+
3
+ Squishling ships an [observer](observers.md) for [Coolhand Labs](https://coolhandlabs.com/). With it, every LLM call a
4
+ squished method makes is filed under a named **workload** and **template** in Coolhand. Squishling knows exactly
5
+ where each call sits (which method, which escalation level, which sample, which judge), so it declares that for you.
6
+
7
+ ## Setup
8
+
9
+ Add and configure the [`coolhand`](https://github.com/Coolhand-Labs/coolhand-ruby) gem as described in
10
+ [Measuring token spend](measuring-tokens.md#coolhand-labs). **That's all.** Squishling defaults to the Coolhand
11
+ observer whenever Coolhand is already available in your codebase: loaded, with an `api_key`, and not disabled
12
+ (`config.enabled = false`). Nothing is added to Squishling's dependencies, and nothing happens when the gem isn't
13
+ there. It needs a version of the `coolhand` gem that has `Coolhand.with_context` (declared workloads); with an older
14
+ one, the observer stays off.
15
+
16
+ To turn it off, set `observer` to `false`:
17
+
18
+ ```ruby
19
+ Squishling.configure do |config|
20
+ config.observer = false # never declare, even though Coolhand is configured
21
+ end
22
+ ```
23
+
24
+ The other settings:
25
+
26
+ ```ruby
27
+ Squishling.configure do |config|
28
+ config.observer = :coolhand # always declare; raises ConfigurationError now if the gem is missing or too old
29
+ config.observer = nil # the default: declare when Coolhand is loaded and configured
30
+ end
31
+ ```
32
+
33
+ To report somewhere else, or as well, use any [other observer](observers.md). `config.observer` holds one.
34
+
35
+ ## What is declared
36
+
37
+ Each request runs inside `Coolhand.with_context(reporter: "squishling", workload: ..., template: ...)`, so Coolhand's
38
+ Net::HTTP interception tags it with the declaration.
39
+
40
+ | | Key | Name |
41
+ |---|---|---|
42
+ | Workload | `Class#method` | `Class#method` (its `description` is `Class#method`, or `Class#method (rescue)` when a [`squish!`](routing.md#handing-off-to-the-llm-with-squish) call handed it to the LLM) |
43
+ | Template | `Class#method/<role>` | `Class#method · <role>` |
44
+
45
+ `<role>` is where the request sits:
46
+
47
+ | Role | Request |
48
+ |---|---|
49
+ | `squish:L<n>` | Attempt *n* (1-based) of the [escalation](configuration.md#models-and-escalation), counting each attempt of each step (a [custom harness](harnesses.md#custom-harnesses) that escalates stage by stage restarts at L1 for each) |
50
+ | `sample:a`, `sample:b`, ... | A [squishsum](harnesses.md) sample (which runs on a worker thread; the declaration is made on that thread) |
51
+ | `judge` | A harness's judge, chat or System One (all of a judge's attempts share this one template) |
52
+
53
+ So `Parser#call/squish:L2` is the second attempt of `Parser#call`, e.g. `Parser#call · squish L2` in Coolhand.
54
+
55
+ - The model is deliberately not in the key. It is on the log, and the position is the stable identity.
56
+ - Repeated attempts on the same step count as separate levels, because the level is the attempt's position in the
57
+ expanded escalation.
58
+ - A class name long enough to push a key past Coolhand's 255-character limit is left undeclared rather than
59
+ breaking the call.
60
+ - Squishling's declaration is made inside any `Coolhand.with_context` you wrapped around the call, so for the fields it
61
+ sets, Squishling's wins.
62
+ - The description never contains the purpose text, which can be a proc that differs per call. The workload's key
63
+ and name stay `Class#method` either way, so calls that went through `squish!` and calls that didn't share one
64
+ workload.
65
+ - `observer = :coolhand` loads the gem when it is assigned, so assign it after `coolhand` is available (the default
66
+ needs no ordering).
data/docs/failures.md CHANGED
@@ -8,26 +8,48 @@ A failed attempt moves on to the next one; once the last fails, the error is rai
8
8
 
9
9
  | Failure | What Squishling does |
10
10
  |---|---|
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`. |
11
+ | Rate limit, 5xx, overload, timeout, connection error | The [adapter](adapters.md) retries at the HTTP level first (RubyLLM: `RubyLLM.config.max_retries`, default 3; OpenAI-compatible: `max_retries:`, default 2). 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` (when the adapter has one: the RubyLLM adapter always does; the OpenAI-compatible adapter for transport errors, not for HTTP statuses). |
12
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` |
13
+ | Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `Squishling::RequestRejectedError` (a `ConfigurationError`) naming the error class, the HTTP status, and the provider's error status/code when its body carries one (`INVALID_ARGUMENT`, `invalid_request_error`). They are also on `error.provider_status` and `error.provider_code` (nil when absent); only plain tokens are kept, never the provider's message. The message points at a param only when the provider named it, and otherwise says the params, output schema, or input may be invalid. The provider's own message is on `cause` when the adapter has one (RubyLLM's does; with the OpenAI-compatible adapter `cause` carries only the status). It is never retried, so if you know a given code means "this input was bad", rescue it and decide for yourself. 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`. When the provider reported a max-tokens cutoff, the error says so and [`truncated?`](#a-cutoff-or-a-wrong-answer) is true. |
15
15
  | Malformed or truncated JSON | Moves to the next attempt, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
16
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
17
  | Schema-valid output that [`squish_validate`](#output-checks-squish_validate) rejects | Moves to the next attempt with your messages, then raises `InvalidOutputError` |
18
- | With a [squishsum or ensemble harness](harnesses.md): two valid samples that differ, with no judge or a judge that rejects both | Raises `Squishling::DisagreementError` (an `InvalidOutputError`), carrying both typed `candidates` |
18
+ | The call has used its [token budget](#capping-a-calls-cost-token-budgets) | Stops before the next attempt and raises `Squishling::BudgetExhaustedError` (an `InvalidOutputError`) |
19
+ | With a [squishsum harness](harnesses.md): two valid samples that differ, with no judge or a judge that rejects both | Raises `Squishling::DisagreementError` (an `InvalidOutputError`), carrying both typed `candidates` |
19
20
 
20
21
  Squishling validates output itself with [json_schemer](https://github.com/davishmcclurg/json_schemer), because
21
- RubyLLM doesn't, and some providers don't enforce strict mode. When the next attempt is on the same step (same
22
- model, provider, params, and `forward_rejected:`), the re-ask happens in the same conversation, so the model sees
23
- what it got wrong. A different step gets a fresh chat with the original input plus the rejected output (truncated to
24
- 4,000 characters) and its errors, so that output is sent to the next step's provider, which may not be the one that
25
- produced it. Set `forward_rejected: false` on a step to leave both out. `InvalidOutputError` exposes `errors`, `raw`
26
- (the last response), `attempts`, and `models` (the model tried on each attempt); for a
27
- [`DisagreementError`](harnesses.md#when-the-harness-fails), `raw` holds both candidates, `attempts` is `nil`, and
28
- `models` has one entry per role (sample a, sample b, then the judge). With a `logger` configured, every
22
+ adapters don't, and some providers don't enforce strict mode. When the next attempt is on the same step (same
23
+ model, provider, params, `forward_rejected:`, and `retry_temperature:`), the re-ask happens in the same conversation, so the model sees
24
+ what it got wrong. If that re-ask returns the same reply it was just told was invalid, Squishling doesn't spend the
25
+ step's remaining attempts: it moves straight to the next step (or raises `InvalidOutputError`, when this was the
26
+ last), so size `attempts:` for replies that can differ. Set `retry_temperature:` on a step to vary the temperature
27
+ of its same-chat retries. A different step gets a fresh chat with the original input plus the rejected output
28
+ (truncated to 4,000 characters) and its errors, so that output is sent to the next step's provider, which may not be the one that
29
+ produced it. Set `forward_rejected: false` on a step to leave both out. `InvalidOutputError` exposes `errors`,
30
+ `error_details` (the same messages, each tagged with where it came from; see below), `raw`
31
+ (the last response), `attempts`, `models` (the model tried on each attempt), and `finish_reason` (why the provider
32
+ stopped on the last one). For a [`DisagreementError`](harnesses.md#when-the-harness-fails), `raw` holds both
33
+ candidates, `attempts` and `finish_reason` are `nil`, and `models` has one entry per role (sample a, sample b, then
34
+ the judge). With a `logger` configured, every
29
35
  escalation is logged as a warning.
30
36
 
37
+ ### A cutoff or a wrong answer
38
+
39
+ An empty or half-written reply can mean the model was cut off at the output cap, not that it answered wrongly. The
40
+ provider's stop reason tells them apart: `InvalidOutputError#finish_reason` and `squawk`'s `metadata[:finish_reason]`
41
+ hold it as a Symbol (`:stop`, `:max_tokens`, `:content_filter`, or the provider's own spelling; `nil` when it reported
42
+ none), and `truncated?` is true for `:max_tokens`. A truncated error's message and the log line say so.
43
+
44
+ ```ruby
45
+ rescue Squishling::InvalidOutputError => e
46
+ e.truncated? # => true: raise max_output_tokens, rather than tuning the prompt or validator
47
+ ```
48
+
49
+ Providers that count thinking against the cap need `max_output_tokens` to leave room for the thinking budget plus the
50
+ answer: `thinking: { budget: 8192 }` with `max_output_tokens: 8192` leaves none, so every reply that uses its full
51
+ budget is cut off before any JSON is written.
52
+
31
53
  ### What ends up in errors and logs
32
54
 
33
55
  Model output can echo sensitive input, so the raw response is kept in one place: `InvalidOutputError#raw` (plus the opt-in `squawk` hook below).
@@ -37,25 +59,92 @@ Model output can echo sensitive input, so the raw response is kept in one place:
37
59
  snippet of the response.
38
60
  - JSON Schema errors, which name the schema path and the rule that failed. They never contain the rejected value, but
39
61
  they do name an extra key the model added (`object property at /foo is a disallowed additional property`), and
40
- that key name is chosen by the model.
62
+ that key name is chosen by the model. These are the only entries Squishling itself fills with model-chosen text; see
63
+ [telling the entries apart](#telling-the-entries-apart).
41
64
  - Your own `squish_validate` messages, verbatim. If you interpolate result values into them, those values reach
42
65
  the message, the logger, and the next attempt's prompt.
43
66
  - A fixed message when a response contains a value JSON can't represent (`1e400` parses to `Infinity`, or a string
44
67
  with invalid UTF-8).
68
+ - For a failed provider request (`LLMError`, and the `ConfigurationError` for a 400, bad credentials, or a forbidden
69
+ key), only the error class and HTTP status, plus a 400's provider status and code when they are plain tokens. The provider's own message can echo request content or part of an API
70
+ key, so it stays on `cause` (`error.cause.message`) for you to read or log deliberately.
45
71
  - At most the first 20 of these errors, plus a count of the rest, so a very large malformed response can't flood
46
72
  the retry message or the log.
47
73
  - For a [`DisagreementError`](harnesses.md#when-the-harness-fails), only what Squishling wrote: a `:judgment` judge's
48
74
  choice and probability. A chat judge's `reason` is model-written, so it is available as `#reason` but never in the
49
75
  message or logs.
50
76
 
77
+ #### Telling the entries apart
78
+
79
+ `InvalidOutputError#errors` is an Array of Strings. `#error_details` is the same list as
80
+ `Squishling::InvalidOutputError::Detail` values (`message`, `source`), so a sink with its own control syntax (a chat
81
+ webhook, HTML, a markdown renderer) can escape only what needs it:
82
+
83
+ | `source` | Where the message came from | `model_derived?` |
84
+ |---|---|---|
85
+ | `:schema` | A JSON Schema error. It can name a key the model chose. | `true` |
86
+ | `:validator` | Your own [`squish_validate`](#output-checks-squish_validate) message, verbatim. Never flagged, so escape it too if you interpolate result values into it. | `false` |
87
+ | `:parse` | A message about a response or return value that didn't parse (not valid JSON, empty, a value JSON can't represent). | `false` |
88
+ | `:squishling` | Other text Squishling or a [custom harness](harnesses.md#when-the-harness-fails) wrote, such as the "... and N more errors" notice. | `false` |
89
+
90
+ ```ruby
91
+ squish_fallback do |error, **|
92
+ trusted = error.error_details.reject(&:model_derived?).map(&:message)
93
+ notify_channel("squishling rejected output: #{trusted.first(2).join('; ')}")
94
+ # ...
95
+ end
96
+ ```
97
+
51
98
  The same applies to anything you log yourself, such as `error.message` in a [fallback](#fallbacks). Use `error.raw`
52
99
  only where you are willing to store model output. To send the output of every attempt somewhere on purpose, use
53
100
  [`squawk`](#observing-every-attempt-squawk).
54
101
 
102
+ ## Capping a call's cost (token budgets)
103
+
104
+ `attempts:` limits retries per step, but a call that fails every attempt still pays for all of them, and with
105
+ reasoning models the failed attempts are often the longest. A token budget caps what one call may use across every
106
+ request it makes, whichever [harness](harnesses.md) it runs:
107
+
108
+ ```ruby
109
+ class TicketTriager
110
+ include Squishling
111
+
112
+ squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, { model: "claude-sonnet-5-5", attempts: 2 }],
113
+ max_total_output_tokens: 25_000,
114
+ max_total_input_tokens: 200_000
115
+ end
116
+ ```
117
+
118
+ Before each request, Squishling checks the tokens used so far by the call's earlier requests (the escalation's
119
+ attempts, a squishsum harness's samples, and a judge's). Once either total has reached its limit, it makes no more
120
+ requests and raises `Squishling::BudgetExhaustedError`. It is an `InvalidOutputError` (so
121
+ [`squish_fallback`](#fallbacks) receives it, and existing `rescue InvalidOutputError` code keeps working), and
122
+ `rescue Squishling::BudgetExhaustedError` tells it apart from a plain validation failure:
123
+
124
+ - `limit` is `:max_total_output_tokens` or `:max_total_input_tokens`, `max` its value, and `used` the tokens spent
125
+ so far (`{ input_tokens:, output_tokens: }`).
126
+ - `errors` starts with the budget line, followed by the validation errors of the last rejected output, which is in
127
+ `raw` (`nil` when no output was rejected, e.g. the samples were valid but there was no budget left for the judge);
128
+ `attempts` and `models` are the requests made.
129
+ - Like any other error message, it carries no model output.
130
+
131
+ Things to know:
132
+
133
+ - The first request always runs, and a request already in flight is never cut short, so a call can end above the
134
+ limit by one request (for the squishsum harnesses, one round of samples).
135
+ - Tokens are the counts the [adapter](adapters.md) reports (`output_tokens` includes a reasoning model's thinking
136
+ tokens for providers that count them that way). A request that failed reports none, and a provider that reports no
137
+ usage never exhausts a budget.
138
+ - The limits are positive Integers. Set them with `Squishling.configure` (`default_max_total_output_tokens`,
139
+ `default_max_total_input_tokens`), `squishling`, `squish :method, ...`, or a [`squish!`](routing.md#handing-off-to-the-llm-with-squish) call.
140
+ The more specific level wins, limit by limit, and `false` lifts a limit set at a wider level.
141
+ - To limit a single response instead, use the `max_output_tokens` [generation param](configuration.md#generation-params).
142
+
55
143
  ## Observing every attempt (`squawk`)
56
144
 
57
145
  `squawk` is an opt-in hook for sending what the model returned to an error tracker or tracing tool. It runs after
58
- every LLM attempt, accepted or rejected, and does nothing unless you set it:
146
+ every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, below), and does nothing unless you
147
+ set it:
59
148
 
60
149
  ```ruby
61
150
  Squishling.configure do |config|
@@ -69,17 +158,36 @@ end
69
158
  |---|---|
70
159
  | `output` | The raw response: a Hash or String, or `nil` when the provider call itself failed |
71
160
  | `error` | `nil` for an accepted attempt. Otherwise the `InvalidOutputError` (with `errors` and `raw`) or `LLMError` that ended the attempt |
72
- | `metadata` | `label` (`"Class#method"`), `attempt`, `attempts` (the escalation's length; under a harness, the sample's or judge's own attempts), `final` (the last attempt), `model`, `provider`, `params`, `input` (the JSON sent: arguments and named context only), `usage` (token counts, when RubyLLM reports them) |
161
+ | `metadata` | `label` (`"Class#method"`), `attempt`, `attempts` (the escalation's length; `attempt` jumps past the attempts a repeated reply skips; under a harness, the sample's or judge's own attempts), `final` (the last attempt, or a rejection after which none follows), `repeated` (`true` when the attempt returned the same reply as the one before it, in the same chat, which skips the step's remaining attempts), `model`, `provider`, `params`, `input` (the JSON sent: arguments and named context only), `usage` (token counts, when the adapter reports them), `finish_reason` (why the provider stopped, e.g. `:stop` or `:max_tokens`; `nil` when unknown or the call failed), `role` (`nil` for an escalation attempt, `:sample`, or `:judge`), `sample` (`"a"`, `"b"`, ... for a sample's attempt, else `nil`) |
73
162
 
74
163
  - Set it globally with `config.squawk`, per class with `squishling squawk: ...`, or per method with
75
164
  `squish :triage, squawk: ...`. The method's hook wins over the class's, which wins over the configured one;
76
165
  `squawk: false` silences an inherited hook. Subclasses and `squish!` calls inherit it.
166
+ - Limit which attempts reach the hook with `squawk_on:`: `:all` (the default), `:rejections` (only attempts that
167
+ errored), `:retries` (those plus any accepted attempt after the first), or `:final` (the last attempt the escalation
168
+ allows, accepted or not; `metadata[:final]`). A classifier that passes on the first try nearly every time can post
169
+ only when something went wrong:
170
+
171
+ ```ruby
172
+ squishling squawk: ->(error:, **) { Tracer.record(error) if error }, squawk_on: :retries
173
+ ```
174
+
175
+ Pass a list to combine conditions; the hook runs when any one holds. `squawk_on: %i[rejections final]` reports
176
+ every error, plus the last attempt when it is reached and succeeds. A call accepted on an earlier attempt never
177
+ reaches `:final`. A level's value replaces the one above it; lists are never merged across levels.
178
+
179
+ Set it globally with `config.squawk_on`, per class with `squishling squawk_on: ...`, or per method with
180
+ `squish :triage, squawk_on: ...`. It resolves on its own, method then class then configured, so
181
+ `config.squawk_on = :retries` filters every hook, including a class's own, unless that class or method overrides it.
182
+ Under a [harness](harnesses.md), `attempt`, `attempts` and `final` count per sample or judge, so a sample's
183
+ first-attempt success is skipped even while another sample retries, and `:final` fires once per sample and judge.
77
184
  - Any object that responds to `call` works. The hook receives only the keywords it declares, unless it takes `**`,
78
185
  so a lambda that wants just `error:` is fine and new metadata fields won't break it.
79
186
  - It runs inline, so keep it quick. Exceptions it raises propagate unchanged and fail the call, like any of your own
80
187
  code, so rescue inside the hook if an outage in your tracing tool shouldn't.
81
188
  - `output` is the same object the result is built from, so treat it as read-only.
82
- - Under a [harness](harnesses.md), it runs for every sample and judge attempt too, each with its own `input`.
189
+ - Under a [harness](harnesses.md), it runs for every sample and judge attempt too, each with its own `input`, and
190
+ `role`/`sample` say which one it was. A [custom harness](harnesses.md#reporting-to-squawk) reports the same way.
83
191
  - It isn't called for a `ConfigurationError`, for deterministic and fallback returns, or for an attempt where your own
84
192
  `squish_validate` raises (that exception propagates first).
85
193
 
@@ -116,17 +224,18 @@ end
116
224
  | Error | Raised when |
117
225
  |---|---|
118
226
  | `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
119
- | `Squishling::ConfigurationError` | Missing purpose or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_to_purpose` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400) |
227
+ | `Squishling::ConfigurationError` | Missing purpose or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_to_purpose` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400, as `RequestRejectedError`, with `provider_status` and `provider_code`) |
120
228
  | `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 |
121
- | `Squishling::DisagreementError` | A subclass of `InvalidOutputError`: a [squishsum or ensemble harness](harnesses.md#when-the-harness-fails)'s samples disagreed and no judge accepted either. `candidates`, `verdict`, and `reason` describe what happened |
122
- | `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) |
229
+ | `Squishling::DisagreementError` | A subclass of `InvalidOutputError`: a [squishsum harness](harnesses.md#when-the-harness-fails)'s samples disagreed and no judge accepted either. `candidates`, `verdict`, and `reason` describe what happened |
230
+ | `Squishling::BudgetExhaustedError` | A subclass of `InvalidOutputError`: the call used up its [token budget](#capping-a-calls-cost-token-budgets) before an attempt was accepted. `limit`, `max`, and `used` describe the budget |
231
+ | `Squishling::LLMError` | The provider call failed on the last attempt in the escalation, after the adapter's own retries, including context-length errors (`cause` holds the original, when the adapter has one) |
123
232
 
124
233
  Errors raised by your own Ruby code are not wrapped.
125
234
 
126
235
  ## Fallbacks
127
236
 
128
237
  Use `squish_fallback` to decide what happens when the LLM path fails with `InvalidOutputError` (including a
129
- `DisagreementError`) or `LLMError`. It's the one fallback for every [harness](harnesses.md).
238
+ `DisagreementError` or `BudgetExhaustedError`) or `LLMError`. It's the one fallback for every [harness](harnesses.md).
130
239
  It receives the error plus the method's inputs as keywords, and runs against the instance:
131
240
 
132
241
  ```ruby