squishling 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c50560e08f93a644589fb8ab529a41eca9ae201b81b9dd9233ecc9ea86873c5
4
- data.tar.gz: f8e2e5bca400137917567449ce299b59a05b40af787631154144f6b986ba8dbb
3
+ metadata.gz: 14b40817f3f49682f1ed311aa5c05007dfc7f55957c9cf89683fd26fea962aa7
4
+ data.tar.gz: 32b93a239eaa8f5994675a24fca6598e5e0a9edeb8d77efa97ebcf9783db6aa4
5
5
  SHA512:
6
- metadata.gz: fa16bccb992c1a7b040254e6c909bb1a36588c4e9fa068ce8eb7d6b5bcf4f2e0394429c15b5d4ab7e90ac39f2cf286367eb44475b7abfce9b9a2f177e1388b3f
7
- data.tar.gz: 97998785808aae5c1182fa6e0780990f512e2ea653856b853e89fbb330e09621f653355aabc6be2e294d2a13c031df5f94143e3bad85fa3f2315609d445bda0d
6
+ metadata.gz: 184955d83ece437fbf86d7f93c2103a1c61302d8df9b70f7430b49b2896c7aab42e04d1f7503203979ff1087ed8e05b67baeb767c9283c43f4bafa82c720abd8
7
+ data.tar.gz: 351cdbe7b06b587e2c236ff75012fae0bf2fa526613a2d79e6b6c37aac643547bc22cfc74f73cb2e965dc31dd5f0234a7addf034fce8b1de5ea8dd74dab529d3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-10-31
11
+
12
+ The v0.5.0 red-team follow-ups (#70): data-egress, secret-leakage, and misclassification fixes. Several of them change
13
+ behavior that used to work, so read **Changed** before upgrading.
14
+
15
+ ### Added
16
+
17
+ - The OpenAI-compatible adapter takes `allow_insecure_http:` (default `false`), `max_response_bytes:` (default 8 MiB), and
18
+ `total_timeout:` (default `open_timeout + 2 * read_timeout`, per attempt). See **Changed** for what they guard.
19
+ (#74, #86)
20
+ - `keep_cause: true` on the RubyLLM adapter keeps the provider's original exception as `cause`. (#76, #84)
21
+ - `Adapter::Base#http_failure(request, status:, body:, via:)`, an overridable `context_length?(status:, body:)` hook, and
22
+ `sanitized_cause(error, status:)` for custom adapters: one shared mapping from an HTTP status to `LLMError`,
23
+ `ConfigurationError`, or `RequestRejectedError`, and a body-free `cause`. See [Adapters](docs/adapters.md). (#72, #76,
24
+ #84, #88)
25
+ - `squawk` metadata gains `stopped:` (`:budget`, `:disagreement`, or `nil` for an ordinary attempt). (#73, #87)
26
+
27
+ ### Changed
28
+
29
+ - **Behavior change:** a `NotImplementedError` raised by code a squished method calls (an abstract method on another
30
+ object, a helper, a block) now propagates instead of sending the method's arguments to the provider. Only the
31
+ method's own stub routes to the LLM: `def x = raise NotImplementedError`, `define_method`, a prepended module, and an
32
+ undefined method. To use the LLM from a rescue deeper in the stack, call `squish!` in the method. (#71, #83)
33
+ - **Behavior change:** `error.cause` from the RubyLLM adapter is now an `Adapter::ProviderFailure` that names only the
34
+ original's class and HTTP status (`RubyLLM::RateLimitError, HTTP 429`) and keeps its backtrace, because the original's
35
+ message is the provider's response body, which can echo request content and which error trackers such as Sentry report
36
+ from the cause chain. This reverses 0.4.0's choice to keep the original: code that read `e.cause.message` or
37
+ `e.cause.is_a?(RubyLLM::...)` needs `keep_cause: true`. The OpenAI-compatible adapter's transport causes carry no body
38
+ and are unchanged. (#76, #84)
39
+ - **Behavior change:** a provider failure is classified by the adapter from its HTTP status and structured error
40
+ `code`/`type`, no longer from the provider's message text. Both adapters now agree: 408, 413, 429, and 5xx are
41
+ `LLMError`; 401, 402, 403, 404, and 3xx are `ConfigurationError`; every other 4xx is a `RequestRejectedError`. On
42
+ RubyLLM, 402, 404, and 422 were retryable `LLMError`s. A context overflow is an `LLMError` (so it retries and
43
+ escalates) only when the body's `code`/`type` is `context_length_exceeded`, the adapter recognizes it (the
44
+ OpenAI-compatible adapter still matches the usual wordings, no longer a bare "maximum ... tokens"), or RubyLLM raised
45
+ its `ContextLengthExceededError`; one that is only in the message text now raises `RequestRejectedError`. (#72, #88)
46
+ - **Behavior change:** the OpenAI-compatible adapter refuses to send an `api_key` or `Authorization` header over
47
+ `http://` to a host that isn't loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), raising
48
+ `ConfigurationError`. A LAN or Docker setup such as `http://ollama:11434/v1` needs `allow_insecure_http: true`.
49
+ Successful responses over `max_response_bytes` raise `LLMError`, requests send `Accept-Encoding: identity` and a
50
+ compressed successful reply is refused, and `total_timeout:` bounds a whole attempt, not each read. None of those is
51
+ retried by the adapter. A failure status (4xx, 5xx, redirect) is classified by its status even when its body is
52
+ compressed or over the cap. (#74, #86)
53
+ - **Behavior change:** a hand-written Hash `output_schema` with `properties` but no `type: "object"`, at the root or
54
+ nested (a property, array `items`, an `optional` branch, a `$ref` target), now raises `ConfigurationError` when the
55
+ schema is built, and `Squishling.verify` reports it. Add `type: "object"` (or a union that includes it). Schematist
56
+ schemas are unaffected. (#77, #81)
57
+ - **Behavior change:** a schema with a remote or unresolvable `$ref` now raises `ConfigurationError` when it is built
58
+ (and in `verify`), not a raw `JSONSchemer::UnknownRef` on the first reply. A Hash or Array argument that contains
59
+ itself raises `ConfigurationError`, not `SystemStackError`. `cached_content` joins the reserved `params` keys. (#78,
60
+ #85)
61
+ - **Behavior change:** the `squawk` hook now also runs once when a call ends on `BudgetExhaustedError` or
62
+ `DisagreementError`, with `output: nil`, `final: true`, and `stopped:` set, so `squawk_on: :final` and `:rejections`
63
+ see those endings. A hook that assumes every call has output or usage should skip calls where `metadata[:stopped]` is
64
+ set. (#73, #87)
65
+ - `Marshal.dump` of an `OpenAICompatible` adapter (or a config holding one) now raises `TypeError`, and its API key and
66
+ header values show as `[REDACTED]` in `to_yaml` and `as_json`, as they already did in `inspect`. Rebuild the adapter
67
+ from its options instead of caching a dump. (#75, #82)
68
+
69
+ ### Fixed
70
+
71
+ - A provider 400 whose error `message`, `code`, or `type` is not valid UTF-8 (for example an echo cut mid-character) now
72
+ raises `RequestRejectedError` like any other 400, instead of a bare `ArgumentError` that is not a Squishling error.
73
+ - A `ConfigurationError` for a rejected `config.adapter`, `observer`, or `squawk` now names the object's class instead of
74
+ its `inspect` output, which could print an instance variable such as an access token.
75
+ - `Schema::CACHE` holds its keys weakly, so schema classes replaced by a Rails code reload can be collected. Two equal
76
+ Hash schemas now share one cached `Schema`. (#78, #85)
77
+
78
+ ### Documented
79
+
80
+ - `Observer::Call#purpose` is the fully rendered system prompt, including `append_to_purpose` source and procs: treat it
81
+ as sensitive. `Testing::Stubs` hooks persist after `Testing.reset!`; `Testing.no_llm_call` swaps the process-wide
82
+ adapter, so it is not safe under parallel test threads; `Run#produced?` compares by identity. (#78, #85)
83
+
10
84
  ## [0.5.0] - 2026-10-28
11
85
 
12
86
  ### Added
data/README.md CHANGED
@@ -151,7 +151,7 @@ A squishling class needs a **purpose** (the system prompt) and an **output schem
151
151
  validated on both paths). A squished call goes to the LLM when:
152
152
 
153
153
  - its `squish_when` predicate is truthy for these inputs,
154
- - the method has no implementation (it isn't defined, or raises `NotImplementedError`), or
154
+ - the method has no implementation (it isn't defined, or its own body raises `NotImplementedError`), or
155
155
  - the Ruby implementation calls `squish!`.
156
156
 
157
157
  Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM output.
@@ -210,8 +210,8 @@ sees it right where the value goes, and the purpose stays short and about the go
210
210
  - **Defined failure behavior**: provider errors become `Squishling::LLMError`, and `squish_fallback` lets you decide
211
211
  what to return when every attempt fails.
212
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.
213
+ raw output (or only the rejected and retried ones), and the end of a call that a token budget or a disagreement
214
+ stopped, to your error tracker. Model output stays out of error messages and logs otherwise.
215
215
  - **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
216
216
  pass to `squish!`, and the source you choose to append are sent to the provider. The one addition: when
217
217
  [escalation](docs/configuration.md#models-and-escalation) moves to the next step, that step also sees the previous
data/docs/adapters.md CHANGED
@@ -37,6 +37,19 @@ Everything works as it did before adapters: configure provider API keys with `Ru
37
37
  use `provider:` for models missing from RubyLLM's registry. See [Configuration](configuration.md). Transport-level
38
38
  retries are RubyLLM's (`RubyLLM.config.max_retries`, `request_timeout`).
39
39
 
40
+ | Option | Default | Description |
41
+ |---|---|---|
42
+ | `keep_cause` | `false` | RubyLLM's exception holds the provider's response body, which can echo your request, and error trackers report the whole `cause` chain. By default the errors the adapter raises carry a sanitized `cause` (the class and HTTP status only). `true` keeps RubyLLM's original exception there. See [the cause of a provider failure](failures.md#the-cause-of-a-provider-failure). |
43
+
44
+ **Failures** are sorted by the HTTP status of the error's response, the same way as for the OpenAI-compatible adapter
45
+ below (an error with no response, such as a timeout, is sorted by its class). RubyLLM's own error classes are not what
46
+ decides: on a 400 its middleware picks a class from the provider's message text, so a bad parameter whose message says
47
+ "per day" would otherwise look like a rate limit. The one RubyLLM class that is honored over the status is
48
+ `RubyLLM::ContextLengthExceededError`, an `LLMError`, since RubyLLM is where each provider's wording for an overlong
49
+ prompt is known. RubyLLM picks that class from the message on a 400, with wording broader than the OpenAI-compatible
50
+ adapter's, so a bad parameter whose message happens to match is retried as an overflow rather than rejected. Each of
51
+ these errors carries a sanitized `cause` (see `keep_cause` above).
52
+
40
53
  This is the only built-in adapter that supports `type: :judgment` judges ([System One decision models](harnesses.md#the-judge)).
41
54
 
42
55
  ## OpenAI-compatible endpoints
@@ -56,28 +69,52 @@ end
56
69
  | Option | Default | Description |
57
70
  |---|---|---|
58
71
  | `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. |
72
+ | `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, and not over plain `http://` to another machine unless `allow_insecure_http: true` (below). `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
73
  | `headers` | `{}` | Extra request headers, e.g. `{ "OpenAI-Organization" => "org-…" }` or a gateway's own. |
61
74
  | `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. |
75
+ | `allow_insecure_http` | `false` | A credential (the `api_key`, or a header named `Authorization`, `Proxy-Authorization`, `api-key`, or `X-Api-Key`; other headers aren't inspected) is never sent over plain `http://` to a host that isn't this machine: building the adapter (or, for a callable key, making the request) raises `ConfigurationError`. Loopback hosts (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`) are exempt, judged by the name as written and never resolved, so a `*.localhost` name counts even on a resolver that doesn't map it to loopback (use the bare `localhost`, or an IP, if you can't rely on that). Set `true` to send it anyway, for a trusted LAN or container network. With no credential, `http://` to any host is fine. |
76
+ | `open_timeout` / `read_timeout` | `10` / `120` | Seconds. `read_timeout` restarts at every read, so it alone doesn't bound a request. |
77
+ | `total_timeout` | `open_timeout + 2 * read_timeout` | Seconds one attempt (each retry gets its own) may take from connecting to the last byte of the response. Past it the call fails with an `LLMError` that isn't retried by the adapter. |
78
+ | `max_response_bytes` | `8388608` (8 MiB) | The most a response body may hold, an error body included, checked against `Content-Length` and the bytes actually read. Over it, a successful response fails with an `LLMError` that isn't retried by the adapter; a failure status drops the body and is classified by its status. Raise it only if you expect very large completions. |
63
79
  | `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
80
  | `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
81
  | `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
82
 
83
+ **Credentials stay out of serializers.** The adapter holds the `api_key` and the `headers` (which may carry an
84
+ `api-key` or `Authorization` value) in an opaque wrapper, not in plain instance variables, so logging or caching the
85
+ adapter or `Squishling.config` can't write them out: `inspect`, `to_s`, `pp`, `to_yaml`, `as_json` (including
86
+ ActiveSupport's `instance_values` walk) and `to_json` show `[REDACTED]`, and `Marshal.dump` raises a `TypeError`
87
+ instead of dumping the key. This guards against accidental serialization (a logged config, an error tracker, a cache
88
+ dump), not against code running in your process, which can still reach the value. If you write your own adapter,
89
+ keep credentials out of plain instance variables the same way, or define `encode_with`, `as_json`, and
90
+ `marshal_dump` to omit them.
91
+
67
92
  **How params map.** `temperature` → `temperature`; `max_output_tokens` → `max_tokens_param`; `thinking: { effort: }` →
68
93
  `reasoning_effort` (`true`/`false` leave the model's default, and a `budget:` or `display:` raises
69
94
  `ConfigurationError`, since Chat Completions has no field for them). Any other param (`top_p`, `seed`, …) goes into
70
95
  the request body as given. Squishling's own keys (`model`, `messages`, `response_format`, `n`, `stream`) always win.
71
96
  `provider:` has no meaning here and is ignored.
72
97
 
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.
98
+ **Failures.** The HTTP status decides (see [the status mapping](#the-error-contract)): 408, 413, 429, 5xx, timeouts, and
99
+ connection errors raise `LLMError` once the adapter's own retries are used up; 401, 402, 403, 404, and a redirect raise
100
+ `ConfigurationError`; any other 4xx (400, 409, 422, …) raises `RequestRejectedError`, never retried or escalated. The
101
+ one exception is a 400 that reports an overlong context, which is an `LLMError` too, since it depends on the input (a
102
+ bigger model may take it). This adapter recognizes one when the error's `code` or `type` is `context_length_exceeded`, or,
103
+ for servers that send neither (vLLM, Ollama, llama.cpp), when its message uses the usual wording ("context length",
104
+ "context window", "context size", "too many tokens", "prompt is too long"). The message is only matched against that
105
+ wording, never repeated; the match is deliberately narrow, so a wording it doesn't know is a rejected request, not a
106
+ retry. Messages carry the status or error class only; a rejected request also carries the server's error status and code
107
+ when they are plain tokens (as `provider_status` and `provider_code`). The server's message, the response body, and the
108
+ API key never appear in an error or log. Redirects aren't followed, so the key can't be sent to another host.
109
+
110
+ **Response limits.** The adapter sends `Accept-Encoding: identity` (it replaces any `Accept-Encoding` header you set)
111
+ and reads the body in chunks. A successful (2xx) response that is compressed anyway, one over `max_response_bytes`,
112
+ and any response that takes longer than `total_timeout` each raise an `LLMError` naming the limit (never the URL or
113
+ any of the response) and are not retried by the adapter, since the same request would meet the same limit;
114
+ escalation and `squish_fallback` handle it like any other `LLMError`. A failure status (4xx, 5xx, or a redirect) is
115
+ classified by its status alone even when its body is compressed or over the cap: the body is dropped unread, so a
116
+ gateway's gzipped 401 is still a `ConfigurationError`, not a transport failure that `squish_fallback` would absorb,
117
+ and a 503 is still retried.
81
118
 
82
119
  ## Writing your own
83
120
 
@@ -105,12 +142,21 @@ class AcmeAdapter < Squishling::Adapter::Base
105
142
  )
106
143
  rescue Acme::AuthError
107
144
  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)
145
+ rescue Acme::HTTPError => e
146
+ # sorted by status like the built-in adapters; only the status and a rejection's status/code tokens are repeated
147
+ raise http_failure(request, status: e.status, body: e.response_body, via: "Acme, HTTP #{e.status}")
111
148
  rescue Acme::Error => e
112
149
  raise llm_error(request, "Acme failed (#{e.class})")
113
150
  end
151
+
152
+ private
153
+
154
+ # Optional: what an overlong prompt looks like for Acme. The default (super) already recognizes a 400 whose error
155
+ # code or type is "context_length_exceeded"; add whatever else Acme's errors tell you, without reading its message.
156
+ # body is the provider's error body, a JSON String or a Hash (or nil); ErrorBody.error reads either safely.
157
+ def context_length?(status:, body:)
158
+ super || (status == 400 && Squishling::Adapter::ErrorBody.error(body)&.[]("code") == "prompt_too_long")
159
+ end
114
160
  end
115
161
 
116
162
  Squishling.configure { |config| config.adapter = AcmeAdapter.new }
@@ -124,7 +170,7 @@ Squishling.register_adapter(:acme, AcmeAdapter)
124
170
 
125
171
  | Field | |
126
172
  |---|---|
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). |
173
+ | `label` | `"Class#method"`, to prefix your error messages (the `configuration_error` / `llm_error` helpers do; `http_failure(request, status:, body:, via:)` sorts an HTTP failure; `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
174
  | `model`, `provider` | The escalation step's, or `nil` when it names none (use your own default). What `provider:` means is up to you. |
129
175
  | `instructions` | The system prompt. |
130
176
  | `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. |
@@ -146,14 +192,36 @@ answer](failures.md#a-cutoff-or-a-wrong-answer).
146
192
 
147
193
  ### The error contract
148
194
 
195
+ Classifying a provider failure is the adapter's job; what happens next is Squishling's.
196
+
197
+ **An adapter defines** which error a failure is. Only the adapter knows its provider's status codes, error bodies, and
198
+ how it says a prompt is too long:
199
+
149
200
  | Raise | When | What Squishling does |
150
201
  |---|---|---|
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`. |
202
+ | `Squishling::ConfigurationError` | Bad credentials, billing, an unknown model or endpoint: anything a retry can't fix | Raised at once. Never retried, escalated, or sent to `squish_fallback`. |
203
+ | `Squishling::RequestRejectedError` (a `ConfigurationError`) | The provider refused the request itself: a 400 or another 4xx for a bad param or schema | Same as `ConfigurationError`, so a setup mistake can't be hidden by a fallback on every call. |
152
204
  | `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
205
 
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
206
+ **Squishling manages** everything that follows from the class: retries, escalation, the fallback, the token budget, and
207
+ `squawk`. It never looks at a provider's status, code, or message; it sees only the error class your adapter raised.
208
+
209
+ For an HTTP provider, `http_failure(request, status:, body:, via:)` does the sorting, so every adapter treats a status
210
+ the same way: 408, 413, 429, 5xx, no status at all (a timeout), and a success status (a reply that was unusable) are
211
+ `LLMError`s; 401, 402, 403, 404, and a redirect are `ConfigurationError`s;
212
+ every other 4xx is a `RequestRejectedError`. `via` ("Acme, HTTP 400") is the only text repeated, plus the status and
213
+ code tokens of a rejection. Between those, one case depends on the provider: a rejected request whose input was too
214
+ long for the model should be an `LLMError`, since a bigger model may take it. `http_failure` asks the
215
+ `context_length?(status:, body:)` hook. Its default recognizes a 400 whose error `code` or `type` is
216
+ `context_length_exceeded` and never reads the message; override it (calling `super`) to recognize what your provider
217
+ sends. Where the adapter can't tell, leave it: the failure stays a `RequestRejectedError`, which is never absorbed.
218
+
219
+ Keep the provider's own message out of every error you raise: it can echo request content or part of a key, and ours
220
+ reach logs and error trackers, and so do trackers that walk the `cause` chain. If the provider's exception carries a
221
+ response body (an HTTP client error), don't leave it as the `cause` that raising inside a `rescue` sets: pass
222
+ `cause: sanitized_cause(error, status: …)`, which builds a stand-in with only the class, the status, and the
223
+ backtrace. An exception that carries no body (a timeout, a refused connection) can stay as the `cause`. Any other
224
+ exception propagates unwrapped and is never retried, so map your transport's errors. Return anything but an
157
225
  `Adapter::Response` and Squishling raises `ConfigurationError`.
158
226
 
159
227
  ### Optional hooks
@@ -33,8 +33,8 @@ end
33
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. |
34
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). |
35
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. |
36
+ | `squawk` | `nil` | A callable run after every LLM attempt (see `squawk_on`), and once more when a token budget or a disagreement stops the call, 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). A call [stopped](failures.md#a-call-that-is-stopped) by a token budget or a disagreement is always reported. Also settable per class and per method. |
38
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
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). |
40
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). |
@@ -242,7 +242,7 @@ end
242
242
 
243
243
  - Keys that Squishling or the adapter control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
244
244
  `response_format`, `text`, `output_config`, `tools`, `tool_choice`, `functions`, `function_call`, `schema`, and the camelCase and plural
245
- spellings other providers use, such as `systemInstruction`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
245
+ spellings other providers use, such as `systemInstruction`, `cachedContent`/`cached_content`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
246
246
  raise `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
247
247
  - **Nested containers** such as Gemini's `generationConfig` (and `generation_config` for Gemini Interactions) and
248
248
  Mistral Conversations' `completion_args` accept ordinary settings, but reject the keys inside them that carry the
data/docs/failures.md CHANGED
@@ -8,9 +8,9 @@ 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 | 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
- | 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 `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. |
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 a `cause` when the adapter has one: the RubyLLM adapter always does, a [sanitized stand-in](#the-cause-of-a-provider-failure); the OpenAI-compatible adapter only for transport errors (the error itself, which carries no response body), not for HTTP statuses. |
12
+ | Bad API key, billing or quota problem (402), unknown model or endpoint (404), missing provider config | Raises `Squishling::ConfigurationError`. Never escalated, never sent to the fallback. |
13
+ | Provider rejects the request (400 Bad Request, or another 4xx such as 409 or 422), 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 not on `cause` either unless you opt in (see [the cause of a provider failure](#the-cause-of-a-provider-failure)). It is never retried, so if you know a given code means "this input was bad", rescue it and decide for yourself. A 400 that reports an overlong input is the exception: the adapter raises an `LLMError` for it (the built-in adapters recognize the structured `context_length_exceeded` code; RubyLLM's own `ContextLengthExceededError` is honored too, and the OpenAI-compatible adapter also recognizes the usual wording of servers that send none), since a bigger model may take it. Which of the two a failure is, is the adapter's call (see [the error contract](adapters.md#the-error-contract)); a wording it doesn't recognize stays a rejected request. Never escalated, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
14
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` |
@@ -65,9 +65,10 @@ Model output can echo sensitive input, so the raw response is kept in one place:
65
65
  the message, the logger, and the next attempt's prompt.
66
66
  - A fixed message when a response contains a value JSON can't represent (`1e400` parses to `Infinity`, or a string
67
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.
68
+ - For a failed provider request (`LLMError`, and the `ConfigurationError` for a rejected request, bad credentials, or a forbidden
69
+ key), only the error class and HTTP status, plus a rejection'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, and error trackers report the whole `cause` chain, so it is not on `cause` either unless you
71
+ [opt in](#the-cause-of-a-provider-failure).
71
72
  - At most the first 20 of these errors, plus a count of the rest, so a very large malformed response can't flood
72
73
  the retry message or the log.
73
74
  - For a [`DisagreementError`](harnesses.md#when-the-harness-fails), only what Squishling wrote: a `:judgment` judge's
@@ -99,6 +100,26 @@ The same applies to anything you log yourself, such as `error.message` in a [fal
99
100
  only where you are willing to store model output. To send the output of every attempt somewhere on purpose, use
100
101
  [`squawk`](#observing-every-attempt-squawk).
101
102
 
103
+ ### The cause of a provider failure
104
+
105
+ A provider's own exception (RubyLLM's, say) carries the provider's response body in its message and `response`, and the
106
+ body can echo your request. Error trackers such as Sentry report the whole `cause` chain, so an `LLMError`,
107
+ `ConfigurationError`, or `RequestRejectedError` raised for a failed request does not carry that exception as its
108
+ `cause`. The [RubyLLM adapter](adapters.md#ruby_llm) puts a `Squishling::Adapter::ProviderFailure` there instead: its
109
+ message is the original's class and HTTP status (`RubyLLM::RateLimitError, HTTP 429`), and it has the original's
110
+ backtrace, but none of its message, response, or other state. Transport errors that carry no body (the OpenAI-compatible
111
+ adapter's timeouts and connection failures) are passed through as they are.
112
+
113
+ To get the original back for debugging, accept that it reaches your tracker and set `keep_cause: true`:
114
+
115
+ ```ruby
116
+ Squishling.configure do |config|
117
+ config.adapter = { type: :ruby_llm, keep_cause: true }
118
+ end
119
+ ```
120
+
121
+ Then `error.cause` is the `RubyLLM::Error` itself, with its message and `response`.
122
+
102
123
  ## Capping a call's cost (token budgets)
103
124
 
104
125
  `attempts:` limits retries per step, but a call that fails every attempt still pays for all of them, and with
@@ -143,8 +164,8 @@ Things to know:
143
164
  ## Observing every attempt (`squawk`)
144
165
 
145
166
  `squawk` is an opt-in hook for sending what the model returned to an error tracker or tracing tool. It runs after
146
- every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, below), and does nothing unless you
147
- set it:
167
+ every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, below), and once more when a token
168
+ budget or a disagreement stops the call. It does nothing unless you set it:
148
169
 
149
170
  ```ruby
150
171
  Squishling.configure do |config|
@@ -157,8 +178,8 @@ end
157
178
  | Keyword | Value |
158
179
  |---|---|
159
180
  | `output` | The raw response: a Hash or String, or `nil` when the provider call itself failed |
160
- | `error` | `nil` for an accepted attempt. Otherwise the `InvalidOutputError` (with `errors` and `raw`) or `LLMError` that ended the attempt |
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`) |
181
+ | `error` | `nil` for an accepted attempt. Otherwise the `InvalidOutputError` (with `errors` and `raw`) or `LLMError` that ended the attempt, or the `BudgetExhaustedError` or `DisagreementError` that stopped the call |
182
+ | `metadata` | `label` (`"Class#method"`), `attempt`, `attempts` (the escalation's length, or for a [stopped call](#a-call-that-is-stopped) the attempts made; `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`), `stopped` (`nil` for an attempt; `:budget` or `:disagreement` for the [stopped call's report](#a-call-that-is-stopped)) |
162
183
 
163
184
  - Set it globally with `config.squawk`, per class with `squishling squawk: ...`, or per method with
164
185
  `squish :triage, squawk: ...`. The method's hook wins over the class's, which wins over the configured one;
@@ -191,6 +212,28 @@ end
191
212
  - It isn't called for a `ConfigurationError`, for deterministic and fallback returns, or for an attempt where your own
192
213
  `squish_validate` raises (that exception propagates first).
193
214
 
215
+ ### A call that is stopped
216
+
217
+ A call that a [token budget](#capping-a-calls-cost-token-budgets) or a
218
+ [disagreement](harnesses.md#when-the-harness-fails) stops has no attempt of its own to report: the attempt before a
219
+ budget stop was reported as not final, and a disagreement's samples were both accepted. So the call gets one more
220
+ report, whatever `squawk_on:` is set to:
221
+
222
+ - `error` is the `BudgetExhaustedError` or `DisagreementError`, `output` is `nil` (nothing was sent), `metadata[:final]`
223
+ is `true`, and `metadata[:stopped]` is `:budget` or `:disagreement` (it is `nil` for every attempt's report).
224
+ - `attempt` and `attempts` are `error.attempts`, or `0` when that is `nil` (a disagreement, or a budget that ran out
225
+ before a sample or judge made its first attempt); a stop before a retry counts the attempts already made. `model` is
226
+ the last of `error.models`, and `provider`, `params`, `usage`, `role`, and `sample` are `nil`.
227
+ - It is sent once, after the attempts that led to it and before `squish_fallback` runs, for the built-in harnesses and
228
+ for a [custom one](harnesses.md#reporting-to-squawk) that raises either error. A call to another squished method
229
+ from your own code (a `squish_validate`, say) reports its own stop, and the outer call does not report that error
230
+ again. The attempts before it keep `final: false`, so `squawk_on: :final` sees a budget or disagreement ending only
231
+ through this report.
232
+ - Like any attempt's report, it runs your hook inline: an exception the hook raises propagates in place of the
233
+ `BudgetExhaustedError` or `DisagreementError` (which stays on `cause`), so `squish_fallback` does not run.
234
+ - `squawk: false`, or no hook, sends nothing. Ending on the last invalid attempt is not a stop: that attempt already
235
+ carried `final: true`.
236
+
194
237
  ## Output checks (`squish_validate`)
195
238
 
196
239
  The schema covers shape and types. For rules it can't express, such as cross-field arithmetic or a lookup against
@@ -224,11 +267,11 @@ end
224
267
  | Error | Raised when |
225
268
  |---|---|
226
269
  | `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
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`) |
270
+ | `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 (a 400 or other rejected 4xx, as `RequestRejectedError`, with `provider_status` and `provider_code`) |
228
271
  | `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 |
229
272
  | `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
273
  | `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) |
274
+ | `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 a [sanitized stand-in](#the-cause-of-a-provider-failure), when the adapter has one) |
232
275
 
233
276
  Errors raised by your own Ruby code are not wrapped.
234
277
 
data/docs/harnesses.md CHANGED
@@ -91,6 +91,13 @@ A judged harness with neither raises `ConfigurationError` before any sample is r
91
91
  step uses only its own `provider:`; it doesn't inherit the class or method `provider:`, which belongs to their models
92
92
  (a chat judge's params, though, do merge over the method's).
93
93
 
94
+ **What the judge is sent.** The judge's provider receives the operation's purpose, the call's full input (the same
95
+ method arguments and named context the samples saw), and **both candidate outputs**, whatever they hold. None of it is
96
+ capped or filtered, and there is no opt-out short of not using a judged harness. When the judge is a different
97
+ provider than the sampled model (a `judge:` step with its own `provider:`, or an escalation's next step on another
98
+ provider), the input and both outputs go to that provider as well, so only name a judge you would trust with the
99
+ input itself.
100
+
94
101
  ### Chat judges
95
102
 
96
103
  By default the judge is a chat model, asked for a strict `{ "verdict": "a" | "b" | "neither", "reason": "..." }`.
@@ -299,7 +306,10 @@ results, the same type the deterministic path returns.
299
306
  | `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
300
307
 
301
308
  `call` returns the accepted result: one a primitive of this run returned. Returning anything else (a Hash, `nil`, a
302
- result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation.
309
+ result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation. The check is
310
+ by object identity, which suits the typed results of an object schema. When the schema isn't an object, a result is a
311
+ bare JSON value, and an immediate such as `1` or `true` counts as produced whatever its source, so there the check can't
312
+ catch a hand-built value.
303
313
 
304
314
  ### When a custom harness fails
305
315
 
@@ -328,6 +338,10 @@ and `attempts` count within that sample, judge, or escalation. `metadata[:repeat
328
338
  sent back the reply it was just rejected for; the primitive then skips the rest of that step's attempts (the next
329
339
  step's `attempt` jumps past them, and a sample ends with its `InvalidOutputError`).
330
340
 
341
+ A call that ends on the token budget or on a `DisagreementError` (the built-in squishsum harnesses', or one your
342
+ `call` raises) is reported once more, as [a stopped call](failures.md#a-call-that-is-stopped), with `metadata[:stopped]`
343
+ set and no output. Squishling does it around your `call`, so a custom harness needs no code for it.
344
+
331
345
  ### Threads
332
346
 
333
347
  Only the provider requests inside `run.samples` run on worker threads. Your `call`, parsing, validation,
@@ -72,6 +72,10 @@ Squishling.configure do |config|
72
72
  end
73
73
  ```
74
74
 
75
+ A call that stops on its [token budget](failures.md#capping-a-calls-cost-token-budgets) or a disagreement is also
76
+ reported once, with `metadata[:stopped]` set (`:budget` or `:disagreement`) and no `usage`, so the hook above logs
77
+ it with empty counts; check `metadata[:stopped]` to count it apart from an attempt.
78
+
75
79
  Each line carries the `"Class#method"` that made the call, so grouping by label gives you tokens per squishling. A call
76
80
  that stays in Ruby makes no LLM request and never reaches the hook, so a label that stops appearing is a path that has
77
81
  been hardened. You can also set the hook per class (`squishling squawk: ...`) or per method. See
data/docs/observers.md CHANGED
@@ -60,7 +60,7 @@ Anything else raises `ConfigurationError` when you assign it.
60
60
  | Field | |
61
61
  |---|---|
62
62
  | `label` | `"Class#method"` |
63
- | `purpose` | The purpose text sent as the system prompt (after `purpose` procs and `append_to_purpose` sections are resolved) |
63
+ | `purpose` | The purpose text sent as the system prompt (after `purpose` procs and `append_to_purpose` sections are resolved). It is the whole prompt, including any source you pass to `append_to_purpose` and whatever your `purpose` procs return, so treat it as sensitive: don't log it to a place the prompt itself shouldn't go |
64
64
  | `handed_off` | `true` when a `squish!` call sent the call to the LLM (typically from a `rescue`), `false` when `squish_when` or a missing implementation did |
65
65
  | `harness` | The harness type (`:escalation`, `:squishsum`, `:judged_squishsum`, or a [custom one](harnesses.md#custom-harnesses)) |
66
66
  | `role` | `nil` for an escalation attempt, `:sample` or `:judge`, as in `squawk`'s `metadata` |
@@ -69,8 +69,8 @@ Anything else raises `ConfigurationError` when you assign it.
69
69
  | `model`, `provider` | The step's model and provider (`provider` is `nil` unless declared) |
70
70
  | `request` | `:chat`, or `:judgment` for a System One judge |
71
71
 
72
- It carries metadata only. The method's inputs, its context, and the model's output never reach an observer: it gets
73
- the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
72
+ It carries metadata, plus the rendered `purpose`. The method's inputs, its context, and the model's output never reach an
73
+ observer: it gets the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
74
74
 
75
75
  ## Errors
76
76
 
data/docs/routing.md CHANGED
@@ -17,7 +17,7 @@ A squished call runs its Ruby implementation unless one of these sends it to the
17
17
  | Trigger | Declared where | Decided |
18
18
  |---|---|---|
19
19
  | `squish_when` (or `when:`) predicate is truthy | class, or per method | before Ruby runs |
20
- | The method has no implementation: it isn't defined, or it raises `NotImplementedError` | the method body | when Ruby gives up |
20
+ | The method has no implementation: it isn't defined, or its own body raises `NotImplementedError` | the method body | when Ruby gives up |
21
21
  | `squish!` | inside the method, e.g. in a `rescue` | after Ruby has partly run, see [Handing off to the LLM](#handing-off-to-the-llm-with-squish) |
22
22
 
23
23
  With no predicate and a working implementation, every call runs Ruby.
@@ -41,8 +41,14 @@ is validated against the schema and turned into the same typed result the LLM pa
41
41
  already has a `result` (see [Naming and collisions](naming.md)). Invalid deterministic output raises
42
42
  `Squishling::InvalidOutputError` too, so a hardened path can't silently drift from the contract.
43
43
 
44
- A `NotImplementedError` raised anywhere inside the method, including from code it calls, also routes to the
45
- LLM.
44
+ Only the method's own stub routes: a `NotImplementedError` raised directly in the method body (`raise
45
+ NotImplementedError` in a `def` or a `define_method` block, including one in a module prepended before
46
+ `include Squishling`, or in a squished parent reached through `super`). One raised by code the method calls
47
+ (a helper, another object, a block, or a `super` into a parent that isn't squished) is a bug in that code, not a
48
+ missing implementation, so it propagates and nothing is sent to the provider. Keep the `raise` in the method body:
49
+ a shared `not_implemented!` helper, or a decorator prepended before `include Squishling` that wraps the method with
50
+ its own `super` frame, hides the stub and the error propagates (use `squish_when { true }` or `squish!` to
51
+ send those calls to the LLM).
46
52
 
47
53
  Each call is routed on its own, including a squished method that calls itself on smaller inputs (those
48
54
  recursive calls must return schema-valid values too). A subclass
data/docs/schemas.md CHANGED
@@ -30,6 +30,11 @@ output_schema(
30
30
  )
31
31
  ```
32
32
 
33
+ A raw schema's `$ref`s must point inside the schema (`"#/$defs/address"`). Squishling resolves them when it builds the
34
+ schema, so one that points nowhere (a remote URI, which is never fetched, or a `$defs` entry that doesn't exist)
35
+ raises `Squishling::ConfigurationError` then, and [`Squishling.verify`](verifying.md) reports it, instead of failing on
36
+ the first response.
37
+
33
38
  ## Describing fields
34
39
 
35
40
  Every DSL type, and `optional`, takes a `description:`. It is sent to the provider as guidance for that field
@@ -84,6 +89,14 @@ fields; rename them (`class` to `ticket_class`). The reserved names are `class`,
84
89
  `deconstruct_keys`, and a few Ruby and Squishling internals (`initialize*`, `__send__`, `squishling_squished`).
85
90
  Other names, such as `method`, `format`, or `type`, work as usual. This applies to nested objects and array items too.
86
91
 
92
+ With a hand-written Hash schema, every object schema that becomes a result (and so a nested result) must also say
93
+ `type: "object"`; `allOf` parts and unions with several non-null branches aren't typed, so they are exempt. A schema with
94
+ `properties` alone accepts a String or Array too (JSON Schema applies `properties` only to objects), which would end
95
+ up in the typed result where a nested result belongs. Squishling raises `ConfigurationError` when the schema is built
96
+ (and `Squishling.verify` reports it) for the root, a nested property, array `items`, the object branch of an
97
+ `optional`, or a `$ref` target. A union that includes it, like `["object", "null"]`, is fine. The DSL always sets
98
+ the type for you.
99
+
87
100
  On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
88
101
  against the schema, and so is anything else you return (`nil`, a string, another schema's result), which raises
89
102
  `Squishling::InvalidOutputError` unless it matches.
data/docs/testing.md CHANGED
@@ -18,7 +18,8 @@ Both give your tests the helpers below and put back, after each test, whatever t
18
18
  still hold what the helpers set), so the rest of your `Squishling.configure` survives.
19
19
 
20
20
  The helpers change global state (`Squishling.config` and the stubbed classes), so tests that use them must not run
21
- in parallel threads.
21
+ in parallel threads. That includes `no_llm_call` / `assert_no_llm_call`, which swap the process-wide `config.adapter`
22
+ for the block: a request another thread makes meanwhile is forbidden too, and may be counted in the block's calls.
22
23
 
23
24
  ## Scripting the model
24
25
 
@@ -132,8 +133,8 @@ stub_squishling_error(PatternGenerator, :llm_error) # also :configurat
132
133
  Attributes that don't satisfy the method's `output_schema` raise `Squishling::Testing::SetupError` when you stub, so a
133
134
  stand-in can't drift from the real result type. Pass `method: :triage` for another squished method. The stubs are
134
135
  removed after each test. A stub applies to that class only, not to its subclasses, which keep their own behavior. The
135
- hook that makes a stub possible stays on the class for the life of the process, so stub the classes of your suite, not
136
- one per test.
136
+ hook that makes a stub possible stays on the class for the life of the process, even after `Testing.reset!` has
137
+ removed the stub (it is inert, but keeps the class alive), so stub the classes of your suite, not one per test.
137
138
 
138
139
  ## Running parts of a method without a model
139
140
 
@@ -171,6 +172,9 @@ capture.calls.map { |call| call.metadata[:attempt] } # output:, error:, and m
171
172
  `with_squawk` swaps `config.squawk` for the block and puts the previous hook back, so an app-wide hook that enqueues
172
173
  a job isn't run in tests. A `squawk:` set on a class or method still takes precedence over the configured one.
173
174
 
175
+ The capture also records the [report of a stopped call](failures.md#a-call-that-is-stopped) (`metadata[:stopped]` is
176
+ `:budget` or `:disagreement`). `stub_error` replaces the method without running Squishling, so it reports nothing.
177
+
174
178
  An observer you configured *before* `fake_squishling` keeps working while the fake is installed (it is chained behind
175
179
  the fake's own). Configure it first: one assigned afterwards replaces the fake's, and then sample and judge scripts
176
180
  can't tell which request is which.