squishling 0.4.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +25 -9
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +53 -18
  6. data/docs/failures.md +123 -18
  7. data/docs/harnesses.md +24 -14
  8. data/docs/measuring-tokens.md +8 -6
  9. data/docs/routing.md +5 -0
  10. data/docs/schemas.md +11 -2
  11. data/docs/testing.md +183 -0
  12. data/docs/verifying.md +84 -0
  13. data/lib/squishling/adapter/error_body.rb +47 -0
  14. data/lib/squishling/adapter.rb +275 -0
  15. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  16. data/lib/squishling/adapters/openai_options.rb +72 -0
  17. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  18. data/lib/squishling/budget.rb +71 -0
  19. data/lib/squishling/class_methods.rb +43 -7
  20. data/lib/squishling/class_registry.rb +63 -0
  21. data/lib/squishling/configuration.rb +57 -6
  22. data/lib/squishling/definition.rb +90 -16
  23. data/lib/squishling/errors.rb +92 -7
  24. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  25. data/lib/squishling/harness/option_types.rb +2 -1
  26. data/lib/squishling/harness/registry.rb +8 -36
  27. data/lib/squishling/invoker.rb +21 -9
  28. data/lib/squishling/judge.rb +16 -3
  29. data/lib/squishling/llm_client.rb +66 -132
  30. data/lib/squishling/model_path.rb +60 -13
  31. data/lib/squishling/named_registry.rb +64 -0
  32. data/lib/squishling/observer.rb +11 -2
  33. data/lib/squishling/output_check.rb +13 -9
  34. data/lib/squishling/params.rb +6 -23
  35. data/lib/squishling/railtie.rb +10 -0
  36. data/lib/squishling/result.rb +37 -16
  37. data/lib/squishling/rotation.rb +5 -0
  38. data/lib/squishling/rspec.rb +18 -0
  39. data/lib/squishling/run/messages.rb +40 -3
  40. data/lib/squishling/run/rejections.rb +43 -0
  41. data/lib/squishling/run.rb +66 -42
  42. data/lib/squishling/schema.rb +10 -9
  43. data/lib/squishling/squawk.rb +33 -3
  44. data/lib/squishling/tasks/squishling.rake +18 -0
  45. data/lib/squishling/testing/adapter.rb +194 -0
  46. data/lib/squishling/testing/checks.rb +39 -0
  47. data/lib/squishling/testing/direct.rb +41 -0
  48. data/lib/squishling/testing/errors.rb +18 -0
  49. data/lib/squishling/testing/fixtures.rb +206 -0
  50. data/lib/squishling/testing/helpers.rb +71 -0
  51. data/lib/squishling/testing/minitest.rb +32 -0
  52. data/lib/squishling/testing/observer.rb +31 -0
  53. data/lib/squishling/testing/reply.rb +60 -0
  54. data/lib/squishling/testing/request.rb +75 -0
  55. data/lib/squishling/testing/rspec.rb +30 -0
  56. data/lib/squishling/testing/script.rb +189 -0
  57. data/lib/squishling/testing/state.rb +73 -0
  58. data/lib/squishling/testing/stubs.rb +112 -0
  59. data/lib/squishling/testing.rb +126 -0
  60. data/lib/squishling/verification.rb +39 -0
  61. data/lib/squishling/verifier.rb +179 -0
  62. data/lib/squishling/version.rb +1 -1
  63. data/lib/squishling.rb +57 -4
  64. metadata +40 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fb55da97a0a032802afcd90b8e56d497a42194206ad90f07d71bc46c70a9e49
4
- data.tar.gz: 79430c869cda0726357c42c1ad1fdc7cbf9108a00ebcd56814df4e45acf99db6
3
+ metadata.gz: 2c50560e08f93a644589fb8ab529a41eca9ae201b81b9dd9233ecc9ea86873c5
4
+ data.tar.gz: f8e2e5bca400137917567449ce299b59a05b40af787631154144f6b986ba8dbb
5
5
  SHA512:
6
- metadata.gz: 95f83f1f99d86b5b2fddcc0d5e7d845aff73dbb7cc85620eafb93f999edd69181e8a72c726409cdb6ec11fa1286264947c48ac5f3467c1fb368755ef8ad95b62
7
- data.tar.gz: 4ad6fbec6185bb9e4369c972b9f561bce840db75a96ba02c619fe82d1726e15230330b8f457c740017b08e412b3da2c364fd40f657db3128b28cab9c01ba993b
6
+ metadata.gz: fa16bccb992c1a7b040254e6c909bb1a36588c4e9fa068ce8eb7d6b5bcf4f2e0394429c15b5d4ab7e90ac39f2cf286367eb44475b7abfce9b9a2f177e1388b3f
7
+ data.tar.gz: 97998785808aae5c1182fa6e0780990f512e2ea653856b853e89fbb330e09621f653355aabc6be2e294d2a13c031df5f94143e3bad85fa3f2315609d445bda0d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - Pluggable adapters: provider requests go through an adapter (`Squishling::Adapter::Base`, a stateless
15
+ `complete(request)`), set with `config.adapter` or registered with `Squishling.register_adapter`. Two ship:
16
+ `:ruby_llm` (the previous behavior) and `:openai_compatible`, plain `Net::HTTP` against any `/chat/completions`
17
+ endpoint with retries and strict `json_schema` output, which keeps keys and provider text out of errors, logs, and
18
+ `inspect`. See [Adapters](docs/adapters.md). (#47)
19
+ - `include_when:` on an escalation step: a Proc called like `squish_when` (the method's inputs as keywords, on the
20
+ instance) that leaves the step out of a call when falsy, for example a model without a BAA. Steps are filtered before
21
+ rotation, and a call left with no steps raises `ConfigurationError`. (#46)
22
+ - `squishling/testing` (opt-in, `require "squishling/testing"`): a scripted adapter that records requests and
23
+ conversations, `assert_no_llm_call`/`forbid_provider_calls!`, schema-validated `stub_result`/`stub_error`,
24
+ `validate`/`fallback_for`/`preview`, schema-driven `valid_reply_for`/`invalid_reply_for`, squawk capture, and
25
+ Minitest and RSpec mixins. See [Testing](docs/testing.md). (#44, #50)
26
+ - `Squishling.verify`/`verify!` check every squished class without a request (output schema and typed result, resolved
27
+ escalation, params and squawk, harness and judge, the keywords `include_when:`/`squish_when` take, and the adapter),
28
+ reporting every problem; `verify!` raises `VerificationError`. Squished classes are tracked in a weak registry
29
+ (`Squishling.squished_classes`). A `squishling:verify` rake task (with a Rails Railtie) and a `be_squishling_valid`
30
+ RSpec matcher (`require "squishling/rspec"`) wrap it. See [Verifying squished classes](docs/verifying.md). (#48, #67)
31
+ - Per-call token budgets: `max_total_output_tokens:` and `max_total_input_tokens:` (on `squishling`, `squish`,
32
+ `squish!`, and `default_max_total_*` in `Squishling.configure`; layered limit by limit, `false` lifts one) cap what one
33
+ call spends across its escalation attempts, squishsum sample rounds, and judge. A request in flight is never cut short,
34
+ so a call can end above the limit by one request. Once a limit is reached the call raises `BudgetExhaustedError`, an
35
+ `InvalidOutputError`, so `squish_fallback` still receives it. (#55, #68)
36
+ - `squawk_on:` (`:all`, `:rejections`, `:retries`, `:final`, or a list) filters which attempts reach the `squawk` hook,
37
+ settable with `config.squawk_on`, `squishling squawk_on:`, and `squish :method, squawk_on:`. The default `:all` keeps
38
+ the old behavior. (#58, #69)
39
+ - `Adapter::Response#finish_reason` (a Symbol such as `:stop` or `:max_tokens`), filled by both built-in adapters.
40
+ `InvalidOutputError` gains `finish_reason` and `truncated?`, with a fixed note in its message and log line when the
41
+ reply was cut off, and `squawk` metadata gains `finish_reason`. (#54, #60)
42
+ - `InvalidOutputError#error_details`: `InvalidOutputError::Detail` entries (`message`, `source` of `:schema`,
43
+ `:validator`, `:parse` or `:squishling`) with a `model_derived?` predicate that is true only for schema errors, which
44
+ can name a key the model chose. Escape just those when forwarding messages to a chat webhook, HTML, or markdown.
45
+ `#errors` is still an Array of Strings (now frozen). (#57, #62)
46
+ - `RequestRejectedError` (a `ConfigurationError`) for a provider's 400, with `provider_status` and `provider_code` read
47
+ as plain tokens from the error body (`INVALID_ARGUMENT`, `invalid_request_error`). The provider's message is still
48
+ never repeated. (#52, #53)
49
+ - `retry_temperature:` on an escalation step sets the temperature for same-chat retries only. (#56, #61)
50
+
51
+ ### Changed
52
+
53
+ - **Breaking change:** `ruby_llm` is no longer a runtime dependency of the gem. Apps that use the RubyLLM adapter (the
54
+ default when no adapter is configured and `ruby_llm` is loadable) must now list `gem "ruby_llm"` in their own
55
+ Gemfile, and `require "ruby_llm"` before `RubyLLM.configure` if they don't use `Bundler.require`; or switch to
56
+ `config.adapter = :openai_compatible`. (#47)
57
+ - **Behavior change:** a schema property named like a method the typed result needs (`class`, `hash`, `to_h`,
58
+ `members`, `inspect`, `is_a?`, ...) now raises `ConfigurationError` when the schema is built, before any request,
59
+ instead of replacing that method on the result class and failing late. Rename such fields (`class` to
60
+ `ticket_class`); the reserved names are listed in [Output schemas](docs/schemas.md). (#43, #49)
61
+ - **Behavior change:** when a same-chat retry returns the exact reply it was just rejected for, Squishling skips the
62
+ step's remaining attempts and moves to the next step (or raises `InvalidOutputError` right away on the last step);
63
+ samples end early the same way. `InvalidOutputError#attempts` counts requests actually made, `squawk` metadata gains
64
+ `repeated:`, and `final:` now means no attempt follows a rejection. Size a step's `attempts:` for replies that can
65
+ differ. (#56, #61)
66
+ - A provider 400's "Check params" hint now names a param only when the provider's error body names one you configured,
67
+ and otherwise says the params, output schema, or input may be invalid. An array-wrapped overlong-context 400 from the
68
+ OpenAI-compatible adapter is now an `LLMError`. (#53)
69
+
70
+ ### Fixed
71
+
72
+ - A raw Hash `output_schema` that omits `type: "object"` no longer lets a non-object reply (a String, Array, or number)
73
+ reach the typed result as a `NoMethodError`; it is now invalid output, retried, escalated, and sent to
74
+ `squish_fallback` like any other. Schemas built with Schematist always had `type: "object"`.
75
+ - `RequestRejectedError`'s `provider_status` and `provider_code` no longer accept a hyphenated token, so a key-shaped
76
+ string a gateway echoes into an error body is never repeated in our message. The RubyLLM adapter's "invalid params"
77
+ `ConfigurationError` now names the param keys, not their values.
78
+
79
+ ### Internal
80
+
81
+ - Development dependencies: `ruby_llm` 2.1.1, `coolhand` 0.10.0, and `rubocop` 1.92.1; CI pins `ruby/setup-ruby` v1.335.0
82
+ so the matrix resolves Ruby 3.3.13, 3.4.12, and 4.0.9. A `finish-release` skill now tags and publishes a reviewed
83
+ release-prep PR. (#45, #51, #59, #63, #64, #65)
84
+
10
85
  ## [0.4.0] - 2026-10-18
11
86
 
12
87
  ### Added
data/README.md CHANGED
@@ -130,12 +130,17 @@ end
130
130
  gem "squishling"
131
131
  ```
132
132
 
133
- Requires Ruby 3.3+ and RubyLLM 2.1+. Configure your provider API keys in RubyLLM as usual, then optionally set a
134
- universal model, or an escalation of models to try in order:
133
+ Requires Ruby 3.3+ and an [adapter](docs/adapters.md) to reach a model: the `ruby_llm` gem (2.1+; Squishling uses it
134
+ automatically when it is in your bundle), or any OpenAI-compatible endpoint, which needs no extra gem. Squishling no
135
+ longer installs or requires `ruby_llm` for you, so add `gem "ruby_llm"` next to it if you use RubyLLM (and
136
+ `require "ruby_llm"` before `RubyLLM.configure` if your app doesn't load its Gemfile with `Bundler.require`). With RubyLLM,
137
+ configure your provider API keys there as usual. Then choose an adapter, and optionally set a universal model, or an
138
+ escalation of models to try in order:
135
139
 
136
140
  ```ruby
137
141
  Squishling.configure do |config|
138
- config.default_model = "claude-sonnet-5-5" # falls back to RubyLLM's default when nil
142
+ config.adapter = :ruby_llm # or { type: :openai_compatible, base_url: "http://localhost:11434/v1" }
143
+ config.default_model = "claude-sonnet-5-5" # falls back to the adapter's default when nil
139
144
  # or: config.default_escalation = [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
140
145
  end
141
146
  ```
@@ -162,11 +167,14 @@ sees it right where the value goes, and the purpose stays short and about the go
162
167
  returned as the same typed `Data` objects. `squished?` tells you which path served a call.
163
168
  - **Your code as context**: `append_to_purpose` adds sections to the prompt, including a class's or method's
164
169
  own Ruby source.
165
- - **Any RubyLLM provider and model**: OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more.
166
- Set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
170
+ - **[Bring your own adapter](docs/adapters.md)**: Squishling doesn't depend on any one LLM client. Use the
171
+ [RubyLLM](https://rubyllm.com) adapter (OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more),
172
+ the OpenAI-compatible adapter (OpenAI, Ollama, vLLM, LM Studio, Together, Groq, … over plain HTTP), or write your
173
+ own in one method.
174
+ - **Any provider and model**: set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
167
175
  reasoning effort, top_p, …).
168
176
  - **Model escalation**: declare an `escalation:` instead of a `model:`, with per-step `attempts:`, `priority:` (models tied on priority take turns going first), provider,
169
- params, and `forward_rejected:`. Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
177
+ params, `forward_rejected:`, `retry_temperature:`, and `include_when:` (leave a model out of calls where a Proc is falsy). Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
170
178
  cross providers, e.g. a local Qwen model on Ollama, then Anthropic Claude Haiku on AWS Bedrock, then Claude Opus:
171
179
 
172
180
  ```ruby
@@ -195,12 +203,15 @@ sees it right where the value goes, and the purpose stays short and about the go
195
203
  - [`:judged_squishsum`](docs/harnesses.md#the-judge): a chat or Jev judge picks between disagreeing samples
196
204
  - [Your own](docs/harnesses.md#custom-harnesses): `Squishling.register_harness` plugs a custom harness into the same
197
205
  machinery (samples, judges, `squawk`, `squish_fallback`)
206
+ - **Cost caps**: `max_total_output_tokens:` and `max_total_input_tokens:` stop a call that keeps failing from
207
+ spending without limit (see [Capping a call's cost](docs/failures.md#capping-a-calls-cost-token-budgets)).
198
208
  - **Output contracts**: beyond the strict schema, conditional rules (`given`) and Ruby checks (`squish_validate`)
199
209
  reject bad output and trigger the next attempt.
200
210
  - **Defined failure behavior**: provider errors become `Squishling::LLMError`, and `squish_fallback` lets you decide
201
211
  what to return when every attempt fails.
202
- - **Observability**: the opt-in [`squawk`](docs/failures.md#observing-every-attempt-squawk) hook sends every attempt's
203
- raw output to your error tracker. Model output stays out of error messages and logs otherwise.
212
+ - **Observability**: the opt-in [`squawk`](docs/failures.md#observing-every-attempt-squawk) hook sends each attempt's
213
+ raw output (or only the rejected and retried ones) to your error tracker. Model output stays out of
214
+ error messages and logs otherwise.
204
215
  - **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
205
216
  pass to `squish!`, and the source you choose to append are sent to the provider. The one addition: when
206
217
  [escalation](docs/configuration.md#models-and-escalation) moves to the next step, that step also sees the previous
@@ -211,23 +222,28 @@ sees it right where the value goes, and the purpose stays short and about the go
211
222
  - [Explainer site](https://coolhand-labs.github.io/squishling/): interactive walkthrough, spectrum slider, and
212
223
  token-cost calculator
213
224
  - [Configuration](docs/configuration.md): options, models and escalation, providers, generation params, inheritance
225
+ - [Adapters](docs/adapters.md): the RubyLLM and OpenAI-compatible adapters, and writing your own
214
226
  - [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `append_to_purpose`, hardening a path,
215
227
  what the LLM sees
216
228
  - [Output schemas](docs/schemas.md): schema forms, strict mode, typed results, optional vs. empty, contracts
217
229
  - [Failure handling](docs/failures.md): escalation, `squish_validate`, error classes, fallbacks
218
230
  - [Harnesses](docs/harnesses.md): escalation, squishsum, judges (chat or Jev), and custom harnesses
231
+ - [Verifying squished classes](docs/verifying.md): `Squishling.verify!`, a rake task, and an RSpec matcher that check
232
+ every class ahead of runtime, without a request
219
233
  - [Naming and collisions](docs/naming.md): the methods `include Squishling` adds and what happens when a name is taken
220
234
  - [Measuring token spend](docs/measuring-tokens.md): see what each squished path costs, with Coolhand Labs,
221
235
  the `squawk` hook, your own observer, or OpenTelemetry
222
236
  - [Observers](docs/observers.md): wrap every provider request, and the built-in [Coolhand Labs](docs/coolhand.md) observer
223
237
  that files each call under its workload and template
238
+ - [Testing](docs/testing.md): `require "squishling/testing"`: a scripted adapter, call assertions, result and error
239
+ stubs, and schema-driven fixtures, for Minitest and RSpec
224
240
  - [Live examples](examples/README.md): end-to-end tests against Anthropic Claude Haiku and OpenAI GPT-6 Luna
225
241
 
226
242
  ## Development
227
243
 
228
244
  ```sh
229
245
  bin/setup
230
- bundle exec rake # RSpec (offline; RubyLLM is stubbed) + RuboCop
246
+ bundle exec rake # RSpec (offline; adapters are faked or stubbed) + RuboCop
231
247
  ```
232
248
 
233
249
  See [AGENTS.md](AGENTS.md) for repo conventions, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
data/docs/adapters.md ADDED
@@ -0,0 +1,189 @@
1
+ # Adapters
2
+
3
+ An **adapter** is how Squishling reaches a model. Squishling builds the request (system prompt, conversation so far,
4
+ strict output schema, generation params), the adapter makes one completion and returns the model's output, and
5
+ Squishling does the rest: validation, `squish_validate`, retries and escalation, harnesses, `squawk`, and fallbacks.
6
+ Two adapters ship, and you can write your own.
7
+
8
+ | Adapter | Reaches | Needs |
9
+ |---|---|---|
10
+ | [`:ruby_llm`](#ruby_llm) | OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and every provider [RubyLLM](https://rubyllm.com) supports | `gem "ruby_llm"` |
11
+ | [`:openai_compatible`](#openai-compatible-endpoints) | Any server that speaks OpenAI's Chat Completions API: OpenAI, Ollama, vLLM, LM Studio, llama.cpp, Together, Groq, … | nothing (Ruby's `Net::HTTP`) |
12
+ | [Your own](#writing-your-own) | Anything | a class with one method |
13
+
14
+ Squishling itself depends on neither RubyLLM nor any provider SDK.
15
+
16
+ ## Choosing one
17
+
18
+ ```ruby
19
+ Squishling.configure do |config|
20
+ config.adapter = :ruby_llm # a built-in or registered adapter, by name
21
+ # config.adapter = { type: :openai_compatible, base_url: "http://localhost:11434/v1", default_model: "llama3.2" }
22
+ # config.adapter = MyAdapter.new # or any adapter instance
23
+ end
24
+ ```
25
+
26
+ An adapter is built when you assign it, so a missing gem or a bad option fails at boot, not on the first request.
27
+ There is one adapter per process; every squishling class, method, and escalation step goes through it.
28
+
29
+ **When you set none**, Squishling uses the RubyLLM adapter if the `ruby_llm` gem is in your bundle, so apps that
30
+ configured RubyLLM before adapters existed keep working. Squishling no longer requires `ruby_llm` when it loads, so an
31
+ app that doesn't load its Gemfile with `Bundler.require` should `require "ruby_llm"` before `RubyLLM.configure`. Without the gem, the first squished call
32
+ raises `Squishling::ConfigurationError` saying what to add.
33
+
34
+ ## `:ruby_llm`
35
+
36
+ Everything works as it did before adapters: configure provider API keys with `RubyLLM.configure`, name models, and
37
+ use `provider:` for models missing from RubyLLM's registry. See [Configuration](configuration.md). Transport-level
38
+ retries are RubyLLM's (`RubyLLM.config.max_retries`, `request_timeout`).
39
+
40
+ This is the only built-in adapter that supports `type: :judgment` judges ([System One decision models](harnesses.md#the-judge)).
41
+
42
+ ## OpenAI-compatible endpoints
43
+
44
+ ```ruby
45
+ Squishling.configure do |config|
46
+ config.adapter = {
47
+ type: :openai_compatible,
48
+ base_url: "http://localhost:11434/v1", # where /chat/completions lives
49
+ api_key: ENV["LLM_API_KEY"], # optional for local servers
50
+ default_model: "llama3.2" # for steps that name no model
51
+ }
52
+ config.default_model = "llama3.2"
53
+ end
54
+ ```
55
+
56
+ | Option | Default | Description |
57
+ |---|---|---|
58
+ | `base_url` | `https://api.openai.com/v1` | The API root. Requests go to `{base_url}/chat/completions`. Must be `http(s)` and carry no credentials, query, or fragment. |
59
+ | `api_key` | `ENV["OPENAI_API_KEY"]` for OpenAI's own endpoint, else none | A String, or a callable returning one (called once per completion, outside the adapter's retry handling, so rotating keys work and its exceptions reach you unchanged). Sent as a bearer token; omitted when blank. The key is sent as-is, so use `https://` for anything off your machine or trusted network. `OPENAI_API_KEY` is read only for OpenAI's own `https://api.openai.com`, so it can never go to another host: pass `api_key:` for any other endpoint that needs one. |
60
+ | `headers` | `{}` | Extra request headers, e.g. `{ "OpenAI-Organization" => "org-…" }` or a gateway's own. |
61
+ | `default_model` | `nil` | The model for steps that don't name one. Without it, a step with no model raises `ConfigurationError`. |
62
+ | `open_timeout` / `read_timeout` | `10` / `120` | Seconds. |
63
+ | `max_retries` | `2` | Timeouts, connection and protocol failures, HTTP 408, 429, and 5xx are retried this many times with exponential backoff (honoring `Retry-After`, up to 30 seconds) before they surface as an `LLMError`. A timed-out request may still have been processed by the server, so a retry can be billed twice; set `max_retries: 0` if that matters more than resilience. |
64
+ | `max_tokens_param` | `:max_completion_tokens` | The body key for `max_output_tokens`. Use `:max_tokens` for servers that don't know the newer name. |
65
+ | `structured_output` | `:json_schema` | `:json_schema` asks for strict structured output. For servers without it use `:json_object`, or `:none`: the schema then goes into the system prompt in words. Squishling validates every answer itself either way. |
66
+
67
+ **How params map.** `temperature` → `temperature`; `max_output_tokens` → `max_tokens_param`; `thinking: { effort: }` →
68
+ `reasoning_effort` (`true`/`false` leave the model's default, and a `budget:` or `display:` raises
69
+ `ConfigurationError`, since Chat Completions has no field for them). Any other param (`top_p`, `seed`, …) goes into
70
+ the request body as given. Squishling's own keys (`model`, `messages`, `response_format`, `n`, `stream`) always win.
71
+ `provider:` has no meaning here and is ignored.
72
+
73
+ **Failures.** Every HTTP status of 300 or above except 408, 429, and 5xx (401, 403, 404, 400, 422, a redirect, …) raises `ConfigurationError`, a `RequestRejectedError` for a 400 or other rejected request (never retried or escalated; a 400 that
74
+ reports an overlong context, and a 413, are `LLMError`s, since they depend on the input; a 400 counts as an overlong
75
+ context when its error code is `context_length_exceeded` or its message uses the usual wording ("context length", "too many
76
+ tokens", …), which the message is only matched against, never repeated). 408, 429, 5xx, timeouts, and
77
+ connection errors raise `LLMError` once the adapter's own retries are used up. Messages carry the status or error
78
+ class only; a rejected request (400) also carries the server's error status and code when they are plain tokens (as
79
+ `provider_status` and `provider_code`). The server's message, the response body, and the API key never appear in an error or log. Redirects
80
+ aren't followed, so the key can't be sent to another host.
81
+
82
+ ## Writing your own
83
+
84
+ Subclass `Squishling::Adapter::Base`, implement `complete`, and return a `Squishling::Adapter::Response`:
85
+
86
+ ```ruby
87
+ class AcmeAdapter < Squishling::Adapter::Base
88
+ def initialize(client: Acme::Client.new)
89
+ super()
90
+ @client = client
91
+ end
92
+
93
+ def complete(request)
94
+ reply = @client.chat(
95
+ model: request.model,
96
+ system: request.instructions,
97
+ messages: request.messages, # [{ role: :user | :assistant, content: "…" }, …]
98
+ json_schema: request.schema["schema"], # strict; Squishling validates the answer regardless
99
+ **request.params
100
+ )
101
+ Squishling::Adapter::Response.new(
102
+ content: reply.text, # the raw String (or a Hash you already parsed)
103
+ model: reply.model,
104
+ tokens: { input_tokens: reply.usage.in, output_tokens: reply.usage.out }
105
+ )
106
+ rescue Acme::AuthError
107
+ raise configuration_error(request, "Acme rejected the credentials")
108
+ rescue Acme::BadRequest => e
109
+ # a 400: repeats only the body's status and code, and points at a param only if the provider named one
110
+ raise rejected_request_error(request, via: "Acme, HTTP 400", body: e.response_body, http_status: 400)
111
+ rescue Acme::Error => e
112
+ raise llm_error(request, "Acme failed (#{e.class})")
113
+ end
114
+ end
115
+
116
+ Squishling.configure { |config| config.adapter = AcmeAdapter.new }
117
+ # or register it by name, to use config.adapter = :acme (or { type: :acme, client: … }):
118
+ Squishling.register_adapter(:acme, AcmeAdapter)
119
+ ```
120
+
121
+ ### The request
122
+
123
+ `Squishling::Adapter::Request` carries everything for one completion:
124
+
125
+ | Field | |
126
+ |---|---|
127
+ | `label` | `"Class#method"`, to prefix your error messages (the `configuration_error` / `llm_error` helpers do; `rejected_request_error(request, via:, body:, http_status:)` builds the error for a rejected request, from a JSON String or String-keyed Hash body). |
128
+ | `model`, `provider` | The escalation step's, or `nil` when it names none (use your own default). What `provider:` means is up to you. |
129
+ | `instructions` | The system prompt. |
130
+ | `messages` | The conversation so far, ending with the user message to answer. After invalid output, Squishling appends your previous answer and a retry message; you never keep history. |
131
+ | `schema` | `{ "name", "description", "schema", "strict" => true }`: the strict structured-output payload, without the conditional keywords providers can't take. |
132
+ | `params` | The resolved [generation params](configuration.md#generation-params): the portable `:temperature`, `:max_output_tokens`, `:thinking`, plus provider-specific keys. Squishling has already rejected the keys it owns. Translate the portable ones for your provider; pass the rest through if it takes them. |
133
+
134
+ ### The response
135
+
136
+ `Squishling::Adapter::Response.new(content:, model:, tokens:, finish_reason:)`. `content` is the model's output as a
137
+ String, or a Hash if you parsed it; `nil` or empty counts as invalid output and is retried. `model` is the id the
138
+ provider reports. `tokens` is a Hash (`:input_tokens`, `:output_tokens`, and anything else you have); `{}` when
139
+ unknown. These feed [`squawk`'s `usage`](failures.md#observing-every-attempt-squawk), the [observer](observers.md)'s `Usage`, and the
140
+ [token budget](failures.md#capping-a-calls-cost-token-budgets), which counts `:input_tokens` and `:output_tokens`
141
+ (a response with none never exhausts a budget).
142
+ `finish_reason` is why the provider stopped, as a Symbol: `:stop`, `:max_tokens` (the output cap cut the reply off),
143
+ `:content_filter`, `:tool_calls`, or the provider's own spelling; leave it out (`nil`) when unknown. Map your
144
+ provider's "length" reason to `:max_tokens`, so Squishling can [tell a cutoff from a wrong
145
+ answer](failures.md#a-cutoff-or-a-wrong-answer).
146
+
147
+ ### The error contract
148
+
149
+ | Raise | When | What Squishling does |
150
+ |---|---|---|
151
+ | `Squishling::ConfigurationError` | Bad credentials, a request the provider rejects, an unknown model: anything a retry can't fix | Raised at once. Never retried, escalated, or sent to `squish_fallback`. |
152
+ | `Squishling::LLMError` | Rate limit, 5xx, timeout, connection failure, an overlong context: anything worth another go | Moves to the next attempt; raised from the last one (and goes to `squish_fallback`). |
153
+
154
+ Keep the provider's own message out of both: it can echo request content or part of a key, and ours reach logs and
155
+ error trackers. Put the original on `cause` (raising inside a `rescue` does that for you). Any other exception
156
+ propagates unwrapped and is never retried, so map your transport's errors. Return anything but an
157
+ `Adapter::Response` and Squishling raises `ConfigurationError`.
158
+
159
+ ### Optional hooks
160
+
161
+ - **`judgments?` and `judge(input, questions:, step:, label:)`**: System One decision models for `type: :judgment` judges.
162
+ `judge` returns an object answering `[:winner]` with something that responds to `choice` and `probabilities`.
163
+ Without `judgments?`, such a judge raises `ConfigurationError` before any request. If the object has `tokens` (with
164
+ `input` and `output` readers), they count toward the call's token budget.
165
+ - **`capture_context` and `with_context(context) { … }`**: samples (the `:squishsum` harnesses) send their requests from
166
+ worker threads. `capture_context` runs on the caller's thread and returns whatever your tracing needs; `with_context`
167
+ re-establishes it around each worker's request. The RubyLLM adapter uses this for its instrumentation workflow.
168
+
169
+ ### Thread safety
170
+
171
+ Adapters are called concurrently from worker threads when a harness takes samples. Keep no unsynchronized mutable
172
+ state (a per-request HTTP connection, as the OpenAI-compatible adapter does, is the simple way).
173
+
174
+ ### Testing against your adapter
175
+
176
+ To test your *application* (not an adapter), use [`squishling/testing`](testing.md): a scripted adapter with request
177
+ inspection, call assertions, and stubs. To unit-test an adapter you wrote, a canned one is enough:
178
+
179
+ ```ruby
180
+ class CannedAdapter < Squishling::Adapter::Base
181
+ def initialize(*replies) = (@replies = replies)
182
+
183
+ def complete(request)
184
+ Squishling::Adapter::Response.new(content: @replies.shift, model: request.model, tokens: {})
185
+ end
186
+ end
187
+
188
+ Squishling.configure { |config| config.adapter = CannedAdapter.new({ "label" => "bug" }) }
189
+ ```
@@ -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,17 +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). |
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. |
32
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). |
33
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). |
34
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). |
35
41
 
36
- Transport-level retries (rate limits, 5xx, timeouts) are configured on RubyLLM itself
37
- (`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.
38
44
 
39
45
  > **`max_retries` was removed.** The escalation now decides how many attempts run, and setting
40
46
  > `config.max_retries` raises `ConfigurationError`. The old default (`max_retries = 1`, two attempts on one model) is
@@ -65,7 +71,9 @@ Each step is a model name, or a Hash with:
65
71
  | `attempts:` | `1` | How many attempts this step gets before escalating to the next one. |
66
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. |
67
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)). |
68
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`. |
69
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). |
70
78
  | `params:` | `{}` | [Generation params](#generation-params) for this step, merged over the class and method params key by key. |
71
79
 
@@ -107,6 +115,25 @@ squishling escalation: [
107
115
  whole tier in the order written, whatever its other steps say.
108
116
  - Rotation also moves which stages the [`:squishsum` and `:judged_squishsum`](harnesses.md) harnesses sample first.
109
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
+
110
137
  `params:` on a step let one step switch to a reasoning model that rejects sampling params:
111
138
 
112
139
  ```ruby
@@ -123,7 +150,7 @@ The first level that declares a `model:` or an `escalation:` supplies the whole
123
150
  2. per method: `squish :name, model: ...` or `escalation: ...`
124
151
  3. per class: `squishling model: ...` or `escalation: ...` (inherited by subclasses)
125
152
  4. universal: `Squishling.config.default_model` or `default_escalation`
126
- 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)
127
154
 
128
155
  ```ruby
129
156
  class InvoiceParser
@@ -139,16 +166,20 @@ end
139
166
  Passing both `model:` and `escalation:` in one declaration raises `ConfigurationError`, and so does a list passed as
140
167
  `model:`. Across separate declarations (a reopened class, or two config assignments), the latest one wins.
141
168
 
142
- 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`)
143
170
  continue one conversation. Moving to a different step starts a fresh chat with the original input plus the previous
144
171
  output and why it was rejected, so no provider-specific history crosses providers. That rejected output is
145
172
  model-generated and is sent to the next step's provider, even when it is a different one, truncated to 4,000
146
173
  characters. If you don't want a step to see it (say, because the next step is a different provider), set
147
174
  `forward_rejected: false` on that step and it starts from the original input only. Configuration errors (bad
148
- 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).
149
177
 
150
178
  ## Providers and newly released models
151
179
 
180
+ This section is about the RubyLLM adapter; the OpenAI-compatible adapter takes model names as given and ignores
181
+ `provider:`.
182
+
152
183
  RubyLLM looks models up in its bundled registry. To use a model that isn't there yet, such as a newly released
153
184
  OpenAI or Anthropic model, name its provider next to it. Squishling then tells RubyLLM to assume the model exists:
154
185
 
@@ -189,14 +220,17 @@ end
189
220
 
190
221
  | Key | Sent as |
191
222
  |---|---|
192
- | `temperature` | RubyLLM's `with_temperature` |
193
- | `max_output_tokens` | RubyLLM's `with_max_output_tokens` (translated to each provider's own field) |
194
- | `thinking` | RubyLLM's `with_thinking`: `true` (the model's default), `false` (off), or options such as `{ effort: :low }`, `{ budget: 1024 }`, `{ display: :omitted }` |
195
- | 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 |
196
227
 
197
228
  - **No params means provider defaults.** Squishling sends nothing unless you set it. For structured
198
229
  extraction on non-reasoning models (e.g. Anthropic Claude Haiku), a low temperature reduces run-to-run
199
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).
200
234
  - **Reasoning models** (e.g. OpenAI GPT-6 Luna) usually reject sampling params like `temperature` and `top_p`;
201
235
  tune `thinking: { effort: }` instead. Anthropic requires `temperature` to be 1 (or unset) when `thinking` is on.
202
236
  - **Unsetting an inherited key:** set it to `nil` to send nothing for that key, so the provider default applies.
@@ -206,8 +240,8 @@ end
206
240
  squish :triage, model: "gpt-6-luna", provider: :openai, params: { temperature: nil, thinking: { effort: :low } }
207
241
  ```
208
242
 
209
- - Keys that Squishling or RubyLLM control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
210
- `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
211
245
  spellings other providers use, such as `systemInstruction`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
212
246
  raise `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
213
247
  - **Nested containers** such as Gemini's `generationConfig` (and `generation_config` for Gemini Interactions) and
@@ -225,7 +259,7 @@ end
225
259
 
226
260
  ## Inheritance
227
261
 
228
- 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,
229
263
  `squish_when` predicate, `squish_context` names, `squish_validate`, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
230
264
  overridden methods, are routed the same way.
231
265
 
@@ -235,5 +269,6 @@ overridden methods, are routed the same way.
235
269
  ## Per-call overrides
236
270
 
237
271
  Inside a squished method, `squish!` sends the call to the LLM with its own `purpose:`,
238
- `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
239
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).